# Welcome

Build AI Agents That Feel Human. In Minutes.

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-c1622ca33076f8f2a73fc27b60a5f64e72a17dfa%2Fhero.avif?alt=media" alt="" height="331" width="200"><figcaption><p>Live in real time. Speech in, synchronized video out.</p></figcaption></figure>

***

## Get started in 2 minutes

The fastest way to deploy a conversational AI agent with a lifelike avatar:

**1. Get your API key** → [Create one here](/getting-started/authentication)

**2. Create an agent** in the [dashboard](https://ojin.ai/dashboard). Pick a face, voice, and personality.

**3. Embed on your site:**

{% tabs %}
{% tab title="Plain HTML" %}

```html
<script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
<ojin-agent
  agent-id="your-agent-id"
  core-api-endpoint="https://api.ojin.ai"
></ojin-agent>
```

{% endtab %}

{% tab title="React" %}
Vite or Create React App. Load the script once, then drop in the tag.

Vite uses the project-root `index.html`. Create React App uses `public/index.html`.

```html
<!-- Vite: index.html. Create React App: public/index.html -->
<script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
```

```tsx
declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "ojin-agent": React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement>,
        HTMLElement
      > & {
        "agent-id"?: string;
        "core-api-endpoint"?: string;
      };
    }
  }
}

export function AgentEmbed() {
  return (
    <ojin-agent
      agent-id="your-agent-id"
      core-api-endpoint="https://api.ojin.ai"
    ></ojin-agent>
  );
}
```

{% endtab %}

{% tab title="Next.js" %}
App Router. Load the script on the client only. The widget uses `window` at mount time:

```tsx
import Script from "next/script";

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "ojin-agent": React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement>,
        HTMLElement
      > & {
        "agent-id"?: string;
        "core-api-endpoint"?: string;
      };
    }
  }
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"
          strategy="afterInteractive"
        />
        <ojin-agent
          agent-id="your-agent-id"
          core-api-endpoint="https://api.ojin.ai"
        />
      </body>
    </html>
  );
}
```

{% endtab %}
{% endtabs %}

That's it. Your users get a live conversational agent with video, audio, and a real-time avatar. [Full Human Agent docs →](/apps/overview)

***

## What Ojin offers

### Apps: deploy in minutes

#### Human Agent

A complete conversational AI agent with a realistic visual avatar. Speech in, speech out, with synchronized lip movements and expressions. Two modes:

* **Ojin Agent:** Ojin handles everything (STT, LLM, TTS, avatar). You configure the personality.
* **Third-Party Agent:** bring your own speech-to-speech provider (Hume, ElevenLabs, Ultravox). Ojin adds the face.

[Get started →](/apps/overview)

### Models: build custom pipelines

Drive Ojin's real-time face models from your own stack. Both are powered by the same [Python SDK](/models/build-with-python-sdk) and [Pipecat](/models/introduction/integrations) integration. Pick the model with its `config_id`.

#### ojin/human-presence

Our **flagship** face model: a fully expressive, generative presence with rich expressions and natural movement (including the hands) that goes beyond lip-sync.

[Learn more →](/models/human-presence)

#### ojin/human-portrait

A **cost-effective**, real-time lipsync model. Transforms a single reference image into a natural animated persona with audio-synchronized lip movements and expressions. Streams at 25 fps, up to 720p.

[Learn more →](/models/human-portrait)

***

## Core features

* **Human Agent:** end-to-end conversational AI with a lifelike visual avatar. One widget embed, no pipeline assembly.
* **Real-time streaming:** WebSocket and WebRTC transport built for live, conversational latency.
* **One-shot personas:** create a lifelike persona from a single reference image. No training, ready immediately.
* **Python SDK and Pipecat:** drive the face models from [`ojin-client`](/models/build-with-python-sdk), or drop [`pipecat-ojin`](/models/introduction/integrations) into a Pipecat pipeline. Raw WebSocket and REST when you need them.
* **Auto-scale:** our infrastructure takes care of the scale for you, your users will never see an unavailable service.
* **Cost-effective:** competitive per-minute pricing with no commitments. $10 free credits to start.

***

## Use cases

* **Customer Support:** deploy lifelike agents for personalized 24/7 support
* **Sales:** greet, qualify, and convert leads with conversational avatars
* **Education:** build interactive tutors with natural speech and expressions
* **Onboarding and Training:** conversational AI for employee learning
* **Brand Ambassador:** always-on, always-on-brand digital representative
* **Healthcare:** empathetic virtual health assistants

***

## LLM-Ready Docs

{% hint style="info" %}
This documentation is optimized for LLM access:

* **MCP Server:** [`docs.ojin.ai/~gitbook/mcp`](https://docs.ojin.ai/~gitbook/mcp)
* [**llms.txt**](https://docs.ojin.ai/llms.txt)**:** structured index
* [**llms-full.txt**](https://docs.ojin.ai/llms-full.txt)**:** full content
* Append `.md` to any page URL for raw markdown

**Note:** `llms.txt` and `llms-full.txt` are auto-generated from the published sitemap. Human Agent pages will appear once they are published in the navigation.
{% endhint %}


# Quickstart

Get up and running with Ojin in minutes.

This guide walks you through both paths, deploying a full conversational agent with the no-code Human Agent app, or driving a real-time face model from your own pipeline with the Python SDK.

## Step 1: Create an API Key

[Get your API key from the Ojin dashboard](/getting-started/authentication). This will allow you to use Ojin in your applications through a secure environment.

{% hint style="warning" %}
Never hardcode your API key directly in your application code or commit it to version control.
{% endhint %}

## Step 2: Choose Your Path

Ojin offers two ways to build with real-time AI, start with whichever fits:

### Deploy a Human Agent, no code (fastest)

Deploy an end-to-end conversational AI agent with a visual avatar. Ojin handles the entire pipeline, or bring your own speech-to-speech provider. No pipeline assembly required.

1. [Create & configure your agent](/apps/overview/configure) in the dashboard (using your API key from Step 1)
2. [Embed the widget](/apps/overview/widget-integration) on your site. One HTML tag, done

### Build with the SDK. Your own pipeline

Use Ojin's real-time face models for the visual avatar layer in your own conversational pipeline. You control the full stack, STT, LLM, TTS, and the [Python SDK](/models/build-with-python-sdk) or [Pipecat](/models/introduction/integrations) turns your TTS audio into a synchronized talking avatar. Pick [`ojin/human-presence`](/models/human-presence) (flagship) or [`ojin/human-portrait`](/models/human-portrait) (cost-effective) with its `config_id`.

1. [Build with the Python SDK](/models/build-with-python-sdk), install, authenticate, and stream your first avatar
2. Or drop the avatar into a [Pipecat](/models/introduction/integrations) voice agent

## Troubleshooting

Run into a snag? Check the [Troubleshooting guide](/guides/troubleshooting) or [reach out to support](/getting-started/support).

## Next Steps

<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>Build with the Python SDK</strong></td><td>Install, authenticate, and stream your first avatar.</td><td><a href="/models/build-with-python-sdk">Python SDK</a></td></tr><tr><td><strong>Human Agent</strong></td><td>Learn more about the full agent product.</td><td><a href="/apps/overview">Human Agent</a></td></tr></tbody></table>


# Get your API key

All requests to the Ojin API require authentication using API keys. This guide explains how to create, manage, and securely use API keys in your applications.

## Creating an API Key

1. **Sign in** to your [Ojin Dashboard](https://ojin.ai)
2. Navigate to the **API Keys** section
3. Click **Create API Key**
4. Enter a descriptive name for your key (e.g., "Development", "Production")
5. Click **Create**
6. **Important**: Copy and store your API key securely. It will only be shown once.

{% hint style="warning" %}
API keys provide full access to your Ojin resources. Never expose them in client-side code, public repositories, or share them with unauthorized individuals.
{% endhint %}

## API Key Best Practices

* **Separate keys** for development and production environments
* **Use environment variables** to store API keys without exposing them publicly
* **Restrict permissions** to only what's needed for each key
* **Rotate keys** periodically for enhanced security
* **Revoke compromised keys** immediately in your dashboard
* **Use secret management services** in production environments
* **Monitor usage** to detect unusual patterns that might indicate a leak


# REST API

Use the REST API to manage persistent Ojin resources such as model configurations, assets, and Human Agents.

The raw OpenAPI specification for this API is also available at [openapi.gitbook.com/o/V9IIQ3Cw10PlDcbzN32h/spec/ojin-rest-api.json](https://openapi.gitbook.com/o/V9IIQ3Cw10PlDcbzN32h/spec/ojin-rest-api.json).

{% hint style="info" %}
This page covers the HTTP REST API. It is separate from Ojin's realtime model APIs, which use WebSockets for streaming media. For example, for `ojin/human-portrait`, see [Realtime API Reference](/models/introduction/api).
{% endhint %}

## Authentication

Authenticated REST endpoints require an API key in the `X-API-Key` header.

```bash
curl https://api.ojin.ai/v1/model-configs \
  -H "X-API-Key: $OJIN_API_KEY"
```

For API key setup guidance, see [Get your API key](/getting-started/authentication).

## Caching

Responses declare `Cache-Control: private, no-store` by default, so neither clients nor intermediaries should store them. A few endpoints are deliberately cacheable and say so in their own `Cache-Control` response header. Read the emitted header rather than assuming a fixed policy or a fixed window.

## Model Configurations

Use model configurations to create and manage reusable settings for Ojin models.

`model_configurations` is variant-specific. To determine the correct object shape, first fetch the target model variant and read its `configuration_schema`, then build your `model_configurations` payload to match that JSON Schema.

## List Model Configurations

> Retrieves a list of Model Configurations. By default returns only organization-owned configurations. Use source='template' to retrieve template configurations instead. Supports pagination and filtering by model variant.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Model Configurations","description":"Operations related to user-defined configurations of model variants for API clients and product integrations."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"ModelVariantIdQueryParameter":{"name":"model_variant_id","in":"query","required":false,"description":"Filter model configurations by the model_variant_id.","schema":{"type":"string"}},"ModelVariantTagQueryParameter":{"name":"model_variant_tag","in":"query","required":false,"description":"Filter model configurations whose model variant contains this tag (e.g. \"_agent-tts\").","schema":{"type":"string"}},"SourceQueryParameter":{"name":"source","in":"query","required":false,"description":"Filter items by ownership source. Use 'org' for items owned by the user's organization, 'template' for items from the template organization. Defaults to 'org'.","schema":{"type":"string","enum":["org","template"],"default":"org"}},"LimitQueryParameter":{"name":"limit","in":"query","required":false,"description":"Number of items to return per page.","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},"OffsetQueryParameter":{"name":"offset","in":"query","required":false,"description":"Number of items to skip for pagination.","schema":{"type":"integer","default":0,"minimum":0}}},"schemas":{"ModelConfig":{"type":"object","description":"Represents a specific configuration of a ModelVariant.","properties":{"model_config_id":{"type":"string","format":"uuid","readOnly":true,"description":"Server-generated unique ID."},"organization_id":{"type":"string","readOnly":true,"description":"Owning organization ID (from external IdP)."},"model_id":{"type":"string","readOnly":true,"description":"ID of the parent Model, derived from the Model Variant."},"model_variant_id":{"type":"string","description":"ID of the ModelVariant being configured. NOT NULL."},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when created via an API key."},"title":{"type":"string","description":"Title for the configuration. NOT NULL, unique per organization_id."},"model_configurations":{"type":"object","additionalProperties":true,"description":"Parameters for the model variant, adhering to its schema. NOT NULL, defaults to '{}'.","default":{}},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation. Defaults to CURRENT_TIMESTAMP."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update. Auto-updates."}},"required":["model_config_id","organization_id","model_id","model_variant_id","created_by","title","model_configurations","created_at","updated_at"]},"Pagination":{"type":"object","properties":{"limit":{"type":"integer","description":"The number of items returned in the current page."},"offset":{"type":"integer","description":"The number of items skipped before starting the current page."},"total_items":{"type":"integer","description":"The total number of items available that match the query."}},"required":["limit","offset","total_items"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/model-configs":{"get":{"tags":["Model Configurations"],"summary":"List Model Configurations","description":"Retrieves a list of Model Configurations. By default returns only organization-owned configurations. Use source='template' to retrieve template configurations instead. Supports pagination and filtering by model variant.","operationId":"listModelConfigs","parameters":[{"$ref":"#/components/parameters/ModelVariantIdQueryParameter"},{"$ref":"#/components/parameters/ModelVariantTagQueryParameter"},{"$ref":"#/components/parameters/SourceQueryParameter"},{"$ref":"#/components/parameters/LimitQueryParameter"},{"$ref":"#/components/parameters/OffsetQueryParameter"}],"responses":{"200":{"description":"A paginated list of model configurations.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ModelConfig"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"}}}}}}
```

## POST /model-configs

> Create a new Model Configuration

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Model Configurations","description":"Operations related to user-defined configurations of model variants for API clients and product integrations."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"schemas":{"ModelConfigCreationRequest":{"type":"object","description":"Payload for creating a new Model Configuration.","properties":{"title":{"type":"string"},"model_variant_id":{"type":"string"},"model_configurations":{"type":"object","additionalProperties":true,"default":{}}},"required":["title","model_variant_id"]},"ModelConfig":{"type":"object","description":"Represents a specific configuration of a ModelVariant.","properties":{"model_config_id":{"type":"string","format":"uuid","readOnly":true,"description":"Server-generated unique ID."},"organization_id":{"type":"string","readOnly":true,"description":"Owning organization ID (from external IdP)."},"model_id":{"type":"string","readOnly":true,"description":"ID of the parent Model, derived from the Model Variant."},"model_variant_id":{"type":"string","description":"ID of the ModelVariant being configured. NOT NULL."},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when created via an API key."},"title":{"type":"string","description":"Title for the configuration. NOT NULL, unique per organization_id."},"model_configurations":{"type":"object","additionalProperties":true,"description":"Parameters for the model variant, adhering to its schema. NOT NULL, defaults to '{}'.","default":{}},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation. Defaults to CURRENT_TIMESTAMP."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update. Auto-updates."}},"required":["model_config_id","organization_id","model_id","model_variant_id","created_by","title","model_configurations","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/model-configs":{"post":{"tags":["Model Configurations"],"summary":"Create a new Model Configuration","operationId":"createModelConfig","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelConfigCreationRequest"}}}},"responses":{"201":{"description":"Model Configuration created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelConfig"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"description":"Referenced ModelVariant not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## GET /model-configs/{model\_config\_id}

> Retrieve a specific Model Configuration

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Model Configurations","description":"Operations related to user-defined configurations of model variants for API clients and product integrations."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"ModelConfigIdPathParameter":{"name":"model_config_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Model Configuration.","schema":{"type":"string","format":"uuid"}}},"schemas":{"ModelConfig":{"type":"object","description":"Represents a specific configuration of a ModelVariant.","properties":{"model_config_id":{"type":"string","format":"uuid","readOnly":true,"description":"Server-generated unique ID."},"organization_id":{"type":"string","readOnly":true,"description":"Owning organization ID (from external IdP)."},"model_id":{"type":"string","readOnly":true,"description":"ID of the parent Model, derived from the Model Variant."},"model_variant_id":{"type":"string","description":"ID of the ModelVariant being configured. NOT NULL."},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when created via an API key."},"title":{"type":"string","description":"Title for the configuration. NOT NULL, unique per organization_id."},"model_configurations":{"type":"object","additionalProperties":true,"description":"Parameters for the model variant, adhering to its schema. NOT NULL, defaults to '{}'.","default":{}},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation. Defaults to CURRENT_TIMESTAMP."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update. Auto-updates."}},"required":["model_config_id","organization_id","model_id","model_variant_id","created_by","title","model_configurations","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/model-configs/{model_config_id}":{"get":{"tags":["Model Configurations"],"summary":"Retrieve a specific Model Configuration","operationId":"getModelConfigById","parameters":[{"$ref":"#/components/parameters/ModelConfigIdPathParameter"}],"responses":{"200":{"description":"Model Configuration details.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelConfig"}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## PUT /model-configs/{model\_config\_id}

> Update an existing Model Configuration

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Model Configurations","description":"Operations related to user-defined configurations of model variants for API clients and product integrations."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"ModelConfigIdPathParameter":{"name":"model_config_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Model Configuration.","schema":{"type":"string","format":"uuid"}}},"schemas":{"ModelConfigUpdateRequest":{"type":"object","description":"Payload for updating a Model Configuration (title and parameters only).","properties":{"title":{"type":"string"},"model_configurations":{"type":"object","additionalProperties":true}},"required":["title","model_configurations"]},"ModelConfig":{"type":"object","description":"Represents a specific configuration of a ModelVariant.","properties":{"model_config_id":{"type":"string","format":"uuid","readOnly":true,"description":"Server-generated unique ID."},"organization_id":{"type":"string","readOnly":true,"description":"Owning organization ID (from external IdP)."},"model_id":{"type":"string","readOnly":true,"description":"ID of the parent Model, derived from the Model Variant."},"model_variant_id":{"type":"string","description":"ID of the ModelVariant being configured. NOT NULL."},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when created via an API key."},"title":{"type":"string","description":"Title for the configuration. NOT NULL, unique per organization_id."},"model_configurations":{"type":"object","additionalProperties":true,"description":"Parameters for the model variant, adhering to its schema. NOT NULL, defaults to '{}'.","default":{}},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation. Defaults to CURRENT_TIMESTAMP."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update. Auto-updates."}},"required":["model_config_id","organization_id","model_id","model_variant_id","created_by","title","model_configurations","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/model-configs/{model_config_id}":{"put":{"tags":["Model Configurations"],"summary":"Update an existing Model Configuration","operationId":"updateModelConfig","parameters":[{"$ref":"#/components/parameters/ModelConfigIdPathParameter"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelConfigUpdateRequest"}}}},"responses":{"200":{"description":"Model Configuration updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelConfig"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## DELETE /model-configs/{model\_config\_id}

> Delete a Model Configuration

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Model Configurations","description":"Operations related to user-defined configurations of model variants for API clients and product integrations."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"ModelConfigIdPathParameter":{"name":"model_config_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Model Configuration.","schema":{"type":"string","format":"uuid"}},"ReferencedByQueryParameter":{"name":"referenced_by","in":"query","required":false,"description":"Delete reference to the agent configuration.","schema":{"type":"string","format":"uuid"}}},"responses":{"NoContentSuccess":{"description":"Operation successful, no content to return."},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"schemas":{"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}}},"paths":{"/model-configs/{model_config_id}":{"delete":{"tags":["Model Configurations"],"summary":"Delete a Model Configuration","operationId":"deleteModelConfig","parameters":[{"$ref":"#/components/parameters/ModelConfigIdPathParameter"},{"$ref":"#/components/parameters/ReferencedByQueryParameter"}],"responses":{"204":{"$ref":"#/components/responses/NoContentSuccess"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"$ref":"#/components/responses/NotFoundError"},"409":{"description":"Conflict - Cannot delete, referenced by AgentConfig.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## Model Variants

Use model variant endpoints to discover public variants and retrieve the `configuration_schema` that defines the expected shape of `model_configurations`.

## List all Model Variants

> List all Model Variants.\
> Public callers, including API-key-authenticated callers, only receive variants with status="public",\
> ignoring other status filters.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Model Variants","description":"Operations related to specific variants of models."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"ModelIdQueryParameter":{"name":"model_id","in":"query","required":false,"description":"Filter model variants by parent model_id.","schema":{"type":"string"}},"StatusQueryParameter":{"name":"status","in":"query","required":false,"description":"Filter by status.","schema":{"type":"string"}},"TagsQueryParameter":{"name":"tags","in":"query","required":false,"description":"Filter model variants by a comma-separated list of tags (AND logic).","style":"form","explode":false,"schema":{"type":"array","items":{"type":"string"}}},"LimitQueryParameter":{"name":"limit","in":"query","required":false,"description":"Number of items to return per page.","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},"OffsetQueryParameter":{"name":"offset","in":"query","required":false,"description":"Number of items to skip for pagination.","schema":{"type":"integer","default":0,"minimum":0}}},"schemas":{"ModelVariant":{"type":"object","description":"Represents a specific variant of a Model.","properties":{"model_variant_id":{"type":"string","description":"Client-provided unique identifier (e.g., \"ojin/oris-v1/standard\")."},"model_id":{"type":"string","description":"Identifier of the parent Model. NOT NULL."},"title":{"type":"string","description":"Display name for the variant. NOT NULL, unique per model_id."},"description":{"type":"object","additionalProperties":{"type":"string"},"description":"UI display texts. NOT NULL, defaults to '{}'."},"preview_media_url":{"type":"string","format":"url","nullable":true,"description":"URL for a preview media."},"configuration_schema":{"type":"object","description":"JSON schema for ModelConfig.model_configurations. NOT NULL, defaults to '{}'.","additionalProperties":true},"tags":{"type":"array","items":{"type":"string"},"description":"List of descriptive tags. NOT NULL, defaults to '[]'."},"status":{"type":"string","description":"Status (e.g., 'available', 'deprecated'). NOT NULL. List managed in code."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of creation. Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of last update. Server-generated. Auto-updates.","readOnly":true}},"required":["model_variant_id","model_id","title","description","configuration_schema","tags","status","created_at","updated_at"]},"Pagination":{"type":"object","properties":{"limit":{"type":"integer","description":"The number of items returned in the current page."},"offset":{"type":"integer","description":"The number of items skipped before starting the current page."},"total_items":{"type":"integer","description":"The total number of items available that match the query."}},"required":["limit","offset","total_items"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/model-variants":{"get":{"tags":["Model Variants"],"summary":"List all Model Variants","description":"List all Model Variants.\nPublic callers, including API-key-authenticated callers, only receive variants with status=\"public\",\nignoring other status filters.","operationId":"listModelVariants","parameters":[{"$ref":"#/components/parameters/ModelIdQueryParameter"},{"$ref":"#/components/parameters/StatusQueryParameter"},{"$ref":"#/components/parameters/TagsQueryParameter"},{"$ref":"#/components/parameters/LimitQueryParameter"},{"$ref":"#/components/parameters/OffsetQueryParameter"}],"responses":{"200":{"description":"A paginated list of model variants.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ModelVariant"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"}}}}}}
```

## Retrieve a specific Model Variant

> Retrieve a specific Model Variant by ID.\
> Public callers, including API-key-authenticated callers, can retrieve the variant only if status="public".\
> Otherwise returns 404.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Model Variants","description":"Operations related to specific variants of models."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"ModelVariantIdPathParameter":{"name":"model_variant_id","in":"path","required":true,"description":"The unique identifier of the model variant.","schema":{"type":"string"}}},"schemas":{"ModelVariant":{"type":"object","description":"Represents a specific variant of a Model.","properties":{"model_variant_id":{"type":"string","description":"Client-provided unique identifier (e.g., \"ojin/oris-v1/standard\")."},"model_id":{"type":"string","description":"Identifier of the parent Model. NOT NULL."},"title":{"type":"string","description":"Display name for the variant. NOT NULL, unique per model_id."},"description":{"type":"object","additionalProperties":{"type":"string"},"description":"UI display texts. NOT NULL, defaults to '{}'."},"preview_media_url":{"type":"string","format":"url","nullable":true,"description":"URL for a preview media."},"configuration_schema":{"type":"object","description":"JSON schema for ModelConfig.model_configurations. NOT NULL, defaults to '{}'.","additionalProperties":true},"tags":{"type":"array","items":{"type":"string"},"description":"List of descriptive tags. NOT NULL, defaults to '[]'."},"status":{"type":"string","description":"Status (e.g., 'available', 'deprecated'). NOT NULL. List managed in code."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of creation. Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of last update. Server-generated. Auto-updates.","readOnly":true}},"required":["model_variant_id","model_id","title","description","configuration_schema","tags","status","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/model-variants/{model_variant_id}":{"get":{"tags":["Model Variants"],"summary":"Retrieve a specific Model Variant","description":"Retrieve a specific Model Variant by ID.\nPublic callers, including API-key-authenticated callers, can retrieve the variant only if status=\"public\".\nOtherwise returns 404.","operationId":"getModelVariantById","parameters":[{"$ref":"#/components/parameters/ModelVariantIdPathParameter"}],"responses":{"200":{"description":"Model Variant details.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelVariant"}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## Assets

Use asset endpoints to upload, register, retrieve, and delete media used by your Ojin integrations.

## Initiate a Multipart Asset Upload

> Starts the multipart asset upload process by creating a multipart upload session in S3.\
> The Core API generates a unique \`asset\_id\` and receives an \`upload\_id\` from S3.\
> Both IDs must be used in subsequent part signing and finalization requests.\
> No Asset record is created in the database at this stage.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"schemas":{"AssetInitiateUploadRequest":{"type":"object","description":"Payload to initiate a multipart asset upload.","properties":{"name":{"type":"string","description":"Original filename of the asset."},"category":{"type":"string","description":"Category for the asset (e.g., 'video', 'image')."},"content_type":{"type":"string","nullable":true,"description":"Client-declared MIME type of the file."},"size_bytes":{"type":"integer","format":"int64","nullable":true,"description":"Client-declared file size in bytes."}},"required":["name","category"]},"AssetInitiateUploadResponse":{"type":"object","description":"Response from initiating a multipart asset upload.","properties":{"asset_id":{"type":"string","format":"uuid","description":"A unique ID generated by Core API for this asset transaction."},"upload_id":{"type":"string","description":"The multipart upload ID from S3, used to identify this multipart upload session."},"s3_key":{"type":"string","description":"The S3 key for this asset."}},"required":["asset_id","upload_id"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/assets/initiate-upload":{"post":{"tags":["Assets"],"summary":"Initiate a Multipart Asset Upload","description":"Starts the multipart asset upload process by creating a multipart upload session in S3.\nThe Core API generates a unique `asset_id` and receives an `upload_id` from S3.\nBoth IDs must be used in subsequent part signing and finalization requests.\nNo Asset record is created in the database at this stage.","operationId":"initiateAssetUpload","requestBody":{"description":"Initial metadata for the asset to be uploaded.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetInitiateUploadRequest"}}}},"responses":{"200":{"description":"Multipart upload initiated successfully. Returns asset_id and upload_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetInitiateUploadResponse"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"}}}}}}
```

## Get Pre-signed URL for Upload Part

> Generates a pre-signed URL for uploading a specific part of a multipart upload.\
> This endpoint will be called multiple times, once for each part of the file.\
> Parts must be numbered sequentially starting from 1, and each part (except the last)\
> must be at least 5MB in size (S3 requirement).

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"schemas":{"AssetSignPartRequest":{"type":"object","description":"Payload to get a pre-signed URL for uploading a specific part of a multipart upload.","properties":{"s3_key":{"type":"string","description":"The S3 key for this asset."},"asset_id":{"type":"string","format":"uuid","description":"The asset ID from the initiate upload response."},"upload_id":{"type":"string","description":"The multipart upload ID from the initiate upload response."},"part_number":{"type":"integer","minimum":1,"maximum":10000,"description":"The part number for this upload part (1-10000)."}},"required":["asset_id","upload_id","part_number"]},"AssetSignPartResponse":{"type":"object","description":"Response containing the pre-signed URL for uploading a specific part.","properties":{"upload_url":{"type":"string","format":"url","description":"The pre-signed S3 URL to PUT this specific part to."},"part_number":{"type":"integer","description":"The part number this URL is for."}},"required":["upload_url","part_number"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/assets/sign-part":{"post":{"tags":["Assets"],"summary":"Get Pre-signed URL for Upload Part","description":"Generates a pre-signed URL for uploading a specific part of a multipart upload.\nThis endpoint will be called multiple times, once for each part of the file.\nParts must be numbered sequentially starting from 1, and each part (except the last)\nmust be at least 5MB in size (S3 requirement).","operationId":"signAssetUploadPart","requestBody":{"description":"Details for the part to be signed.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetSignPartRequest"}}}},"responses":{"200":{"description":"Pre-signed URL generated successfully for the specified part.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetSignPartResponse"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"description":"Multipart upload session not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## Finalize Multipart Upload and Create Asset Record

> Completes a multipart upload by combining all uploaded parts and creates the Asset\
> metadata record in the Core API. The client must provide all part numbers and their\
> corresponding ETags from the S3 upload responses. The Core API will complete the\
> multipart upload in S3 and verify the final object before creating the database record.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"schemas":{"AssetFinalizationRequest":{"type":"object","description":"Payload to finalize a multipart asset upload and create the asset metadata record in DB.","properties":{"asset_id":{"type":"string","format":"uuid","description":"The unique ID received from the 'initiate-upload' step."},"upload_id":{"type":"string","description":"The multipart upload ID from the 'initiate-upload' step."},"name":{"type":"string","description":"The original filename (must be consistent with initiate request)."},"category":{"type":"string","description":"The asset category (must be consistent with initiate request)."},"content_type":{"type":"string","description":"Final confirmed MIME type of the asset."},"size_bytes":{"type":"integer","format":"int64","description":"Final confirmed size of the asset in bytes."},"parts":{"type":"array","items":{"$ref":"#/components/schemas/AssetUploadPart"},"description":"List of all uploaded parts with their ETags, in order.","minItems":1}},"required":["asset_id","upload_id","name","category","content_type","size_bytes","parts"]},"AssetUploadPart":{"type":"object","description":"Information about a completed upload part.","properties":{"part_number":{"type":"integer","minimum":1,"maximum":10000,"description":"The part number that was uploaded."},"etag":{"type":"string","description":"The ETag returned by S3 after successfully uploading this part."}},"required":["part_number","etag"]},"Asset":{"type":"object","description":"Represents a digital asset managed by the Core API.","properties":{"asset_id":{"type":"string","format":"uuid","description":"Unique identifier for the Asset, generated during upload initiation.","readOnly":true},"organization_id":{"type":"string","description":"ID of the Organisation (from external IdP) that owns this Asset. Server-set.","readOnly":true},"created_by":{"type":"string","description":"User ID of the creator (from external IdP), or `api_key:<id>` when created via an API key. Server-set.","readOnly":true},"name":{"type":"string","description":"The original file name of the Asset. NOT NULL."},"category":{"type":"string","description":"Primary category of the Asset (e.g., 'video', 'image', 'weight'). NOT NULL. List of values managed in code."},"content_type":{"type":"string","description":"The MIME type of the asset. NOT NULL."},"size_bytes":{"type":"integer","format":"int64","description":"The size of the asset in bytes. NOT NULL."},"etag":{"type":"string","description":"The ETag of the S3 object, used for integrity checking. NOT NULL."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of when this Asset record was created (finalized). Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of the last update to this Asset record. Server-generated. Auto-updates on modification.","readOnly":true},"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}},"required":["asset_id","organization_id","created_by","name","category","content_type","size_bytes","etag","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ConflictError":{"description":"Conflict with the current state of the resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/assets":{"post":{"tags":["Assets"],"summary":"Finalize Multipart Upload and Create Asset Record","description":"Completes a multipart upload by combining all uploaded parts and creates the Asset\nmetadata record in the Core API. The client must provide all part numbers and their\ncorresponding ETags from the S3 upload responses. The Core API will complete the\nmultipart upload in S3 and verify the final object before creating the database record.","operationId":"createAssetRecord","requestBody":{"description":"Finalization details for the multipart asset upload.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetFinalizationRequest"}}}},"responses":{"201":{"description":"Multipart upload completed successfully and Asset record created in DB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"description":"Multipart upload session not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"$ref":"#/components/responses/ConflictError"}}}}}}
```

## List Assets

> Retrieves a list of Asset metadata. By default returns only organization-owned assets. Use source='template' to retrieve template assets instead. Supports pagination and filtering by category and name.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"SourceQueryParameter":{"name":"source","in":"query","required":false,"description":"Filter items by ownership source. Use 'org' for items owned by the user's organization, 'template' for items from the template organization. Defaults to 'org'.","schema":{"type":"string","enum":["org","template"],"default":"org"}},"AssetCategoryQueryParameter":{"name":"category","in":"query","required":false,"description":"Filter assets by category.","schema":{"type":"string"}},"AssetNameQueryParameter":{"name":"name","in":"query","required":false,"description":"Filter assets by name (e.g., for partial match - specific behavior TBD by implementation).","schema":{"type":"string"}},"LimitQueryParameter":{"name":"limit","in":"query","required":false,"description":"Number of items to return per page.","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},"OffsetQueryParameter":{"name":"offset","in":"query","required":false,"description":"Number of items to skip for pagination.","schema":{"type":"integer","default":0,"minimum":0}}},"schemas":{"Asset":{"type":"object","description":"Represents a digital asset managed by the Core API.","properties":{"asset_id":{"type":"string","format":"uuid","description":"Unique identifier for the Asset, generated during upload initiation.","readOnly":true},"organization_id":{"type":"string","description":"ID of the Organisation (from external IdP) that owns this Asset. Server-set.","readOnly":true},"created_by":{"type":"string","description":"User ID of the creator (from external IdP), or `api_key:<id>` when created via an API key. Server-set.","readOnly":true},"name":{"type":"string","description":"The original file name of the Asset. NOT NULL."},"category":{"type":"string","description":"Primary category of the Asset (e.g., 'video', 'image', 'weight'). NOT NULL. List of values managed in code."},"content_type":{"type":"string","description":"The MIME type of the asset. NOT NULL."},"size_bytes":{"type":"integer","format":"int64","description":"The size of the asset in bytes. NOT NULL."},"etag":{"type":"string","description":"The ETag of the S3 object, used for integrity checking. NOT NULL."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of when this Asset record was created (finalized). Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of the last update to this Asset record. Server-generated. Auto-updates on modification.","readOnly":true},"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}},"required":["asset_id","organization_id","created_by","name","category","content_type","size_bytes","etag","created_at","updated_at"]},"Pagination":{"type":"object","properties":{"limit":{"type":"integer","description":"The number of items returned in the current page."},"offset":{"type":"integer","description":"The number of items skipped before starting the current page."},"total_items":{"type":"integer","description":"The total number of items available that match the query."}},"required":["limit","offset","total_items"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/assets":{"get":{"tags":["Assets"],"summary":"List Assets","description":"Retrieves a list of Asset metadata. By default returns only organization-owned assets. Use source='template' to retrieve template assets instead. Supports pagination and filtering by category and name.","operationId":"listAssets","parameters":[{"$ref":"#/components/parameters/SourceQueryParameter"},{"$ref":"#/components/parameters/AssetCategoryQueryParameter"},{"$ref":"#/components/parameters/AssetNameQueryParameter"},{"$ref":"#/components/parameters/LimitQueryParameter"},{"$ref":"#/components/parameters/OffsetQueryParameter"}],"responses":{"200":{"description":"A paginated list of Asset metadata.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Asset"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"}}}}}}
```

## Retrieve Asset Metadata and Download URL

> Retrieves metadata for a specific Asset, including a pre-signed URL for downloading the content. The user can access an asset if it belongs to their organization or to the shared template organization.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AssetIdPathParameter":{"name":"asset_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Asset.","schema":{"type":"string","format":"uuid"}}},"schemas":{"Asset":{"type":"object","description":"Represents a digital asset managed by the Core API.","properties":{"asset_id":{"type":"string","format":"uuid","description":"Unique identifier for the Asset, generated during upload initiation.","readOnly":true},"organization_id":{"type":"string","description":"ID of the Organisation (from external IdP) that owns this Asset. Server-set.","readOnly":true},"created_by":{"type":"string","description":"User ID of the creator (from external IdP), or `api_key:<id>` when created via an API key. Server-set.","readOnly":true},"name":{"type":"string","description":"The original file name of the Asset. NOT NULL."},"category":{"type":"string","description":"Primary category of the Asset (e.g., 'video', 'image', 'weight'). NOT NULL. List of values managed in code."},"content_type":{"type":"string","description":"The MIME type of the asset. NOT NULL."},"size_bytes":{"type":"integer","format":"int64","description":"The size of the asset in bytes. NOT NULL."},"etag":{"type":"string","description":"The ETag of the S3 object, used for integrity checking. NOT NULL."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of when this Asset record was created (finalized). Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of the last update to this Asset record. Server-generated. Auto-updates on modification.","readOnly":true},"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}},"required":["asset_id","organization_id","created_by","name","category","content_type","size_bytes","etag","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/assets/{asset_id}/download":{"get":{"tags":["Assets"],"summary":"Retrieve Asset Metadata and Download URL","description":"Retrieves metadata for a specific Asset, including a pre-signed URL for downloading the content. The user can access an asset if it belongs to their organization or to the shared template organization.","operationId":"getAssetDownloadById","parameters":[{"$ref":"#/components/parameters/AssetIdPathParameter"}],"responses":{"200":{"description":"Successfully retrieved Asset metadata, including a download URL.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Asset"},{"type":"object","properties":{"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}}}]}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## Retrieve Asset Metadata

> Retrieves metadata for a specific Asset, including a temporary pre-signed \`asset\_url\` for download. The user can access an asset if it belongs to their organization or to the shared template organization.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AssetIdPathParameter":{"name":"asset_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Asset.","schema":{"type":"string","format":"uuid"}}},"schemas":{"Asset":{"type":"object","description":"Represents a digital asset managed by the Core API.","properties":{"asset_id":{"type":"string","format":"uuid","description":"Unique identifier for the Asset, generated during upload initiation.","readOnly":true},"organization_id":{"type":"string","description":"ID of the Organisation (from external IdP) that owns this Asset. Server-set.","readOnly":true},"created_by":{"type":"string","description":"User ID of the creator (from external IdP), or `api_key:<id>` when created via an API key. Server-set.","readOnly":true},"name":{"type":"string","description":"The original file name of the Asset. NOT NULL."},"category":{"type":"string","description":"Primary category of the Asset (e.g., 'video', 'image', 'weight'). NOT NULL. List of values managed in code."},"content_type":{"type":"string","description":"The MIME type of the asset. NOT NULL."},"size_bytes":{"type":"integer","format":"int64","description":"The size of the asset in bytes. NOT NULL."},"etag":{"type":"string","description":"The ETag of the S3 object, used for integrity checking. NOT NULL."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of when this Asset record was created (finalized). Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of the last update to this Asset record. Server-generated. Auto-updates on modification.","readOnly":true},"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}},"required":["asset_id","organization_id","created_by","name","category","content_type","size_bytes","etag","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/assets/{asset_id}":{"get":{"tags":["Assets"],"summary":"Retrieve Asset Metadata","description":"Retrieves metadata for a specific Asset, including a temporary pre-signed `asset_url` for download. The user can access an asset if it belongs to their organization or to the shared template organization.","operationId":"getAssetById","parameters":[{"$ref":"#/components/parameters/AssetIdPathParameter"}],"responses":{"200":{"description":"Successfully retrieved Asset metadata.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset"}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## Delete an Asset

> Permanently deletes an Asset's metadata from the DB and its corresponding object from S3 (hard delete). This operation is restricted to assets owned by the user's organization and cannot be used on shared template assets.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AssetIdPathParameter":{"name":"asset_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Asset.","schema":{"type":"string","format":"uuid"}}},"responses":{"NoContentSuccess":{"description":"Operation successful, no content to return."},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"schemas":{"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}}},"paths":{"/assets/{asset_id}":{"delete":{"tags":["Assets"],"summary":"Delete an Asset","description":"Permanently deletes an Asset's metadata from the DB and its corresponding object from S3 (hard delete). This operation is restricted to assets owned by the user's organization and cannot be used on shared template assets.","operationId":"deleteAsset","parameters":[{"$ref":"#/components/parameters/AssetIdPathParameter"}],"responses":{"204":{"$ref":"#/components/responses/NoContentSuccess"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

{% hint style="warning" %}
The idle video generator endpoint still references the deprecated `ojin/oris-1.0` model name. The underlying service uses the current Human Portrait model. This endpoint path will be updated in a future release.
{% endhint %}

## Generate idle video from image asset

> Triggers background generation of an idle video from a source image asset\
> using the ojin/oris-1.0 idle video generator.\
> \
> The job runs asynchronously and returns a \`job\_id\` for status tracking.\
> \
> On completion, the generated video is saved as a new Asset in the organization.\
> The \`result.asset\_id\` field in the job object will contain the new asset ID.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Assets","description":"Operations related to managing digital assets (images, videos, etc.)."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"schemas":{"IdleVideoGenerationRequest":{"type":"object","description":"Request payload to trigger idle video generation.","properties":{"source_asset_id":{"type":"string","format":"uuid","description":"The ID of the source image asset to use for generating the idle video."},"reference_template":{"type":"string","description":"The reference motion template to use (e.g., 'v1', 'v2', 'v3').","enum":["v1","v2","v3"]},"model_variant_id":{"type":"string","maxLength":128,"description":"Model variant the generated idle video is intended for (e.g. 'ojin/oris-portrait'). Used only to label the background job so the Recent Generations list names the right model. Optional; callers without a model context omit it and the job uses a default label."},"target_model_config_id":{"type":"string","format":"uuid","description":"Model config to assign the generated video to as its active idle preview once the job completes. When set, core-api writes model_configurations.preview server-side on completion, so the assignment lands even if the browser closed, navigated away, or refreshed mid-generation. Must be a config owned by the caller's organization. Optional; omit to only create the video without assigning it."}},"required":["source_asset_id"]},"IdleVideoGenerationResponse":{"type":"object","description":"Response from triggering idle video generation.","properties":{"job_id":{"type":"string","format":"uuid","description":"Identifier of the background job created for this generation request."}},"required":["job_id"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ForbiddenError":{"description":"Authenticated principal does not have permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/assets/generate/ojin/oris-1.0-idle-video-generator":{"post":{"tags":["Assets"],"summary":"Generate idle video from image asset","description":"Triggers background generation of an idle video from a source image asset\nusing the ojin/oris-1.0 idle video generator.\n\nThe job runs asynchronously and returns a `job_id` for status tracking.\n\nOn completion, the generated video is saved as a new Asset in the organization.\nThe `result.asset_id` field in the job object will contain the new asset ID.","operationId":"generateIdleVideo","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdleVideoGenerationRequest"}}}},"responses":{"202":{"description":"Job accepted and queued for processing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdleVideoGenerationResponse"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"403":{"$ref":"#/components/responses/ForbiddenError"},"404":{"description":"Source asset not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}}}
```

## Agents

Use the agent endpoints to create and manage Human Agents: their configuration, publication status, third-party voice providers, and call history. These manage the persistent agent definition; to start a live session with a published agent, see the [Session API Reference](/apps/overview/api-reference).

## GET /agents

> List Agents (org-scoped)

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"LimitQueryParameter":{"name":"limit","in":"query","required":false,"description":"Number of items to return per page.","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},"OffsetQueryParameter":{"name":"offset","in":"query","required":false,"description":"Number of items to skip for pagination.","schema":{"type":"integer","default":0,"minimum":0}}},"schemas":{"AgentBase":{"type":"object","description":"Basic representation of an Agent for list views.","properties":{"agent_id":{"type":"string","format":"uuid","readOnly":true},"organization_id":{"type":"string","format":"uuid","readOnly":true},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when the resource was created via an API key."},"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"status":{"$ref":"#/components/schemas/AgentStatus"},"face_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time","readOnly":true},"updated_at":{"type":"string","format":"date-time","readOnly":true}},"required":["agent_id","organization_id","created_by","title","mode","status","face_id","created_at","updated_at"]},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents":{"get":{"tags":["Agents"],"summary":"List Agents (org-scoped)","operationId":"listAgents","parameters":[{"$ref":"#/components/parameters/LimitQueryParameter"},{"$ref":"#/components/parameters/OffsetQueryParameter"}],"responses":{"200":{"description":"Paginated list of Agents.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AgentBase"}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total_items":{"type":"integer"}},"required":["limit","offset","total_items"]}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"}}}}}}
```

## Create a new Agent

> Creates an Agent, optionally seeded from a preset. On creation the response includes \`auth\_secret\` (one-shot) when \`auth\_enabled\` is true.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"schemas":{"CreateAgentRequest":{"type":"object","description":"Payload for creating a new Agent, optionally from a preset.","properties":{"title":{"type":"string","maxLength":255},"mode":{"$ref":"#/components/schemas/AgentMode"},"preset_id":{"type":"string","format":"uuid","description":"Optional preset to seed the new agent from."}},"required":["title"]},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentDetail":{"allOf":[{"$ref":"#/components/schemas/AgentBase"},{"type":"object","description":"Full Agent detail including configuration, STS credentials flags, LLM tool wiring.","properties":{"language":{"$ref":"#/components/schemas/AgentLanguage"},"system_prompt":{"type":"string","maxLength":10000,"nullable":true},"tts_provider":{"type":"string"},"voice_id":{"type":"string","maxLength":255,"nullable":true},"behaviour":{"allOf":[{"$ref":"#/components/schemas/AgentBehaviour"}],"nullable":true},"advanced":{"allOf":[{"$ref":"#/components/schemas/AgentAdvanced"}],"nullable":true},"sts_provider":{"type":"string","maxLength":100,"nullable":true},"sts_config_id":{"type":"string","maxLength":255,"nullable":true},"sts_api_key_set":{"type":"boolean","description":"True when an encrypted STS API key is persisted for this agent. The raw key is never returned."},"provider_config":{"type":"object","additionalProperties":true,"nullable":true},"preview_url":{"type":"string","maxLength":2048,"nullable":true,"description":"A direct URL or a bare asset-ID UUID. The public agent endpoint resolves UUIDs to signed S3 URLs before returning them to clients."},"starts_conversation":{"type":"boolean"},"session_limit_seconds":{"type":"integer","minimum":1,"maximum":86400},"max_concurrency":{"oneOf":[{"type":"integer","enum":[-1]},{"type":"integer","minimum":1,"maximum":100}],"description":"Maximum concurrent sessions. -1 means unbounded (no limit); valid positive values are 1–100."},"auth_enabled":{"type":"boolean"},"auth_secret_set":{"type":"boolean","description":"True when an auth secret is persisted."},"auth_secret":{"type":"string","nullable":true,"description":"Plaintext auth secret. Only returned to the creator immediately after\nagent creation (one-shot). Subsequent reads return null; use auth_secret_set\nto detect whether a secret is configured."},"allowed_hostnames":{"type":"array","nullable":true,"items":{"type":"string","maxLength":255}},"tools":{"type":"array","description":"Per-tool function-calling definitions with delivery + enabled flag.","items":{"$ref":"#/components/schemas/AgentTool"}}},"required":["language","tts_provider","sts_api_key_set","starts_conversation","session_limit_seconds","max_concurrency","auth_enabled","auth_secret_set","tools"]}]},"AgentBase":{"type":"object","description":"Basic representation of an Agent for list views.","properties":{"agent_id":{"type":"string","format":"uuid","readOnly":true},"organization_id":{"type":"string","format":"uuid","readOnly":true},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when the resource was created via an API key."},"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"status":{"$ref":"#/components/schemas/AgentStatus"},"face_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time","readOnly":true},"updated_at":{"type":"string","format":"date-time","readOnly":true}},"required":["agent_id","organization_id","created_by","title","mode","status","face_id","created_at","updated_at"]},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."},"AgentLanguage":{"type":"string","enum":["en","de","fr","es","ar","ja"],"description":"Spoken language code for the agent. Drives Deepgram STT language,\nElevenLabs voice filtering in the dashboard, and an LLM\nsystem-prompt suffix that pins the response language.\n"},"AgentBehaviour":{"type":"object","description":"Conversational behaviour knobs applied to an Agent.","properties":{"greeting":{"type":"string","maxLength":2000,"nullable":true},"greeting_reference":{"type":"string","maxLength":2000,"nullable":true},"greeting_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"nudge_message":{"type":"string","maxLength":2000,"nullable":true},"nudge_reference":{"type":"string","maxLength":2000,"nullable":true},"nudge_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"farewell_message":{"type":"string","maxLength":2000,"nullable":true},"farewell_reference":{"type":"string","maxLength":2000,"nullable":true},"farewell_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"max_conversation_duration_seconds":{"type":"integer","minimum":1,"maximum":86400,"nullable":true},"inactivity_nudge_seconds":{"type":"integer","minimum":1,"maximum":3600,"nullable":true},"allow_interruption":{"type":"boolean","nullable":true}}},"AgentAdvanced":{"type":"object","description":"Advanced configuration for an Agent (video pipeline + LLM tuning).","properties":{"max_video_source_dimension":{"type":"integer","minimum":1,"nullable":true},"llm_max_tokens":{"type":"integer","minimum":64,"maximum":4096,"nullable":true,"description":"Maximum completion tokens the managed (Ojin-mode) LLM may generate per spoken reply. Raise it if answers get cut off mid-sentence; lower it to keep replies short. Defaults to 384 when unset."}}},"AgentTool":{"type":"object","additionalProperties":false,"description":"A function-calling tool exposed by the agent to the LLM, with per-tool delivery and enabled flag.","properties":{"tool_id":{"type":"string","format":"uuid","readOnly":true},"enabled":{"type":"boolean","default":true,"description":"When false, the tool is hidden from the LLM but its delivery config is preserved."},"type":{"type":"string","enum":["function"],"default":"function"},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["tool_id","enabled","type","function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents":{"post":{"tags":["Agents"],"summary":"Create a new Agent","description":"Creates an Agent, optionally seeded from a preset. On creation the response includes `auth_secret` (one-shot) when `auth_enabled` is true.","operationId":"createAgent","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAgentRequest"}}}},"responses":{"201":{"description":"Agent created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentDetail"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"}}}}}}
```

## GET /agents/{agent\_id}

> Retrieve an Agent

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AgentIdPathParameter":{"name":"agent_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Agent.","schema":{"type":"string","format":"uuid"}}},"schemas":{"AgentDetail":{"allOf":[{"$ref":"#/components/schemas/AgentBase"},{"type":"object","description":"Full Agent detail including configuration, STS credentials flags, LLM tool wiring.","properties":{"language":{"$ref":"#/components/schemas/AgentLanguage"},"system_prompt":{"type":"string","maxLength":10000,"nullable":true},"tts_provider":{"type":"string"},"voice_id":{"type":"string","maxLength":255,"nullable":true},"behaviour":{"allOf":[{"$ref":"#/components/schemas/AgentBehaviour"}],"nullable":true},"advanced":{"allOf":[{"$ref":"#/components/schemas/AgentAdvanced"}],"nullable":true},"sts_provider":{"type":"string","maxLength":100,"nullable":true},"sts_config_id":{"type":"string","maxLength":255,"nullable":true},"sts_api_key_set":{"type":"boolean","description":"True when an encrypted STS API key is persisted for this agent. The raw key is never returned."},"provider_config":{"type":"object","additionalProperties":true,"nullable":true},"preview_url":{"type":"string","maxLength":2048,"nullable":true,"description":"A direct URL or a bare asset-ID UUID. The public agent endpoint resolves UUIDs to signed S3 URLs before returning them to clients."},"starts_conversation":{"type":"boolean"},"session_limit_seconds":{"type":"integer","minimum":1,"maximum":86400},"max_concurrency":{"oneOf":[{"type":"integer","enum":[-1]},{"type":"integer","minimum":1,"maximum":100}],"description":"Maximum concurrent sessions. -1 means unbounded (no limit); valid positive values are 1–100."},"auth_enabled":{"type":"boolean"},"auth_secret_set":{"type":"boolean","description":"True when an auth secret is persisted."},"auth_secret":{"type":"string","nullable":true,"description":"Plaintext auth secret. Only returned to the creator immediately after\nagent creation (one-shot). Subsequent reads return null; use auth_secret_set\nto detect whether a secret is configured."},"allowed_hostnames":{"type":"array","nullable":true,"items":{"type":"string","maxLength":255}},"tools":{"type":"array","description":"Per-tool function-calling definitions with delivery + enabled flag.","items":{"$ref":"#/components/schemas/AgentTool"}}},"required":["language","tts_provider","sts_api_key_set","starts_conversation","session_limit_seconds","max_concurrency","auth_enabled","auth_secret_set","tools"]}]},"AgentBase":{"type":"object","description":"Basic representation of an Agent for list views.","properties":{"agent_id":{"type":"string","format":"uuid","readOnly":true},"organization_id":{"type":"string","format":"uuid","readOnly":true},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when the resource was created via an API key."},"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"status":{"$ref":"#/components/schemas/AgentStatus"},"face_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time","readOnly":true},"updated_at":{"type":"string","format":"date-time","readOnly":true}},"required":["agent_id","organization_id","created_by","title","mode","status","face_id","created_at","updated_at"]},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."},"AgentLanguage":{"type":"string","enum":["en","de","fr","es","ar","ja"],"description":"Spoken language code for the agent. Drives Deepgram STT language,\nElevenLabs voice filtering in the dashboard, and an LLM\nsystem-prompt suffix that pins the response language.\n"},"AgentBehaviour":{"type":"object","description":"Conversational behaviour knobs applied to an Agent.","properties":{"greeting":{"type":"string","maxLength":2000,"nullable":true},"greeting_reference":{"type":"string","maxLength":2000,"nullable":true},"greeting_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"nudge_message":{"type":"string","maxLength":2000,"nullable":true},"nudge_reference":{"type":"string","maxLength":2000,"nullable":true},"nudge_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"farewell_message":{"type":"string","maxLength":2000,"nullable":true},"farewell_reference":{"type":"string","maxLength":2000,"nullable":true},"farewell_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"max_conversation_duration_seconds":{"type":"integer","minimum":1,"maximum":86400,"nullable":true},"inactivity_nudge_seconds":{"type":"integer","minimum":1,"maximum":3600,"nullable":true},"allow_interruption":{"type":"boolean","nullable":true}}},"AgentAdvanced":{"type":"object","description":"Advanced configuration for an Agent (video pipeline + LLM tuning).","properties":{"max_video_source_dimension":{"type":"integer","minimum":1,"nullable":true},"llm_max_tokens":{"type":"integer","minimum":64,"maximum":4096,"nullable":true,"description":"Maximum completion tokens the managed (Ojin-mode) LLM may generate per spoken reply. Raise it if answers get cut off mid-sentence; lower it to keep replies short. Defaults to 384 when unset."}}},"AgentTool":{"type":"object","additionalProperties":false,"description":"A function-calling tool exposed by the agent to the LLM, with per-tool delivery and enabled flag.","properties":{"tool_id":{"type":"string","format":"uuid","readOnly":true},"enabled":{"type":"boolean","default":true,"description":"When false, the tool is hidden from the LLM but its delivery config is preserved."},"type":{"type":"string","enum":["function"],"default":"function"},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["tool_id","enabled","type","function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents/{agent_id}":{"get":{"tags":["Agents"],"summary":"Retrieve an Agent","operationId":"getAgent","parameters":[{"$ref":"#/components/parameters/AgentIdPathParameter"}],"responses":{"200":{"description":"Agent detail.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentDetail"}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## PATCH /agents/{agent\_id}

> Update an Agent (partial)

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AgentIdPathParameter":{"name":"agent_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Agent.","schema":{"type":"string","format":"uuid"}}},"schemas":{"UpdateAgentRequest":{"type":"object","description":"Payload for updating an Agent (partial update — all fields optional).","properties":{"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"face_id":{"type":"string","format":"uuid","nullable":true},"language":{"$ref":"#/components/schemas/AgentLanguage"},"system_prompt":{"type":"string","maxLength":10000,"nullable":true},"tts_provider":{"type":"string","enum":["elevenlabs","ojin"]},"voice_id":{"type":"string","maxLength":255,"nullable":true},"behaviour":{"$ref":"#/components/schemas/AgentBehaviour"},"advanced":{"$ref":"#/components/schemas/AgentAdvanced"},"sts_provider":{"type":"string","maxLength":100,"nullable":true},"sts_api_key":{"type":"string","maxLength":1000,"nullable":true,"writeOnly":true},"sts_config_id":{"type":"string","maxLength":255,"nullable":true},"provider_config":{"type":"object","additionalProperties":true,"nullable":true},"preview_url":{"type":"string","maxLength":2048,"nullable":true,"description":"A direct URL or a bare asset-ID UUID. The public agent endpoint resolves UUIDs to signed S3 URLs before returning them to clients."},"session_limit_seconds":{"type":"integer","minimum":1,"maximum":86400},"max_concurrency":{"oneOf":[{"type":"integer","enum":[-1]},{"type":"integer","minimum":1,"maximum":100}],"description":"Maximum concurrent sessions. -1 means unbounded (no limit); valid positive values are 1–100."},"starts_conversation":{"type":"boolean"},"auth_enabled":{"type":"boolean"},"allowed_hostnames":{"type":"array","items":{"type":"string","maxLength":255}},"tools":{"type":"array","description":"Replace the agent's tools with this array (full-replace semantics).\nOmit to leave tools unchanged. UPSERTs by `function.name`; tools not\npresent in the array are deleted from the agent.","items":{"$ref":"#/components/schemas/AgentToolInput"}}}},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentLanguage":{"type":"string","enum":["en","de","fr","es","ar","ja"],"description":"Spoken language code for the agent. Drives Deepgram STT language,\nElevenLabs voice filtering in the dashboard, and an LLM\nsystem-prompt suffix that pins the response language.\n"},"AgentBehaviour":{"type":"object","description":"Conversational behaviour knobs applied to an Agent.","properties":{"greeting":{"type":"string","maxLength":2000,"nullable":true},"greeting_reference":{"type":"string","maxLength":2000,"nullable":true},"greeting_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"nudge_message":{"type":"string","maxLength":2000,"nullable":true},"nudge_reference":{"type":"string","maxLength":2000,"nullable":true},"nudge_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"farewell_message":{"type":"string","maxLength":2000,"nullable":true},"farewell_reference":{"type":"string","maxLength":2000,"nullable":true},"farewell_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"max_conversation_duration_seconds":{"type":"integer","minimum":1,"maximum":86400,"nullable":true},"inactivity_nudge_seconds":{"type":"integer","minimum":1,"maximum":3600,"nullable":true},"allow_interruption":{"type":"boolean","nullable":true}}},"AgentAdvanced":{"type":"object","description":"Advanced configuration for an Agent (video pipeline + LLM tuning).","properties":{"max_video_source_dimension":{"type":"integer","minimum":1,"nullable":true},"llm_max_tokens":{"type":"integer","minimum":64,"maximum":4096,"nullable":true,"description":"Maximum completion tokens the managed (Ojin-mode) LLM may generate per spoken reply. Raise it if answers get cut off mid-sentence; lower it to keep replies short. Defaults to 384 when unset."}}},"AgentToolInput":{"type":"object","additionalProperties":false,"description":"Input shape for an agent tool. `tool_id` is optional — when present and\nmatched (by `function.name`) the existing row is updated in place; when\nabsent or unmatched a new tool is created. `enabled` defaults to true\nand `type` defaults to \"function\" server-side when omitted.","properties":{"tool_id":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"},"type":{"type":"string","enum":["function"]},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]},"AgentDetail":{"allOf":[{"$ref":"#/components/schemas/AgentBase"},{"type":"object","description":"Full Agent detail including configuration, STS credentials flags, LLM tool wiring.","properties":{"language":{"$ref":"#/components/schemas/AgentLanguage"},"system_prompt":{"type":"string","maxLength":10000,"nullable":true},"tts_provider":{"type":"string"},"voice_id":{"type":"string","maxLength":255,"nullable":true},"behaviour":{"allOf":[{"$ref":"#/components/schemas/AgentBehaviour"}],"nullable":true},"advanced":{"allOf":[{"$ref":"#/components/schemas/AgentAdvanced"}],"nullable":true},"sts_provider":{"type":"string","maxLength":100,"nullable":true},"sts_config_id":{"type":"string","maxLength":255,"nullable":true},"sts_api_key_set":{"type":"boolean","description":"True when an encrypted STS API key is persisted for this agent. The raw key is never returned."},"provider_config":{"type":"object","additionalProperties":true,"nullable":true},"preview_url":{"type":"string","maxLength":2048,"nullable":true,"description":"A direct URL or a bare asset-ID UUID. The public agent endpoint resolves UUIDs to signed S3 URLs before returning them to clients."},"starts_conversation":{"type":"boolean"},"session_limit_seconds":{"type":"integer","minimum":1,"maximum":86400},"max_concurrency":{"oneOf":[{"type":"integer","enum":[-1]},{"type":"integer","minimum":1,"maximum":100}],"description":"Maximum concurrent sessions. -1 means unbounded (no limit); valid positive values are 1–100."},"auth_enabled":{"type":"boolean"},"auth_secret_set":{"type":"boolean","description":"True when an auth secret is persisted."},"auth_secret":{"type":"string","nullable":true,"description":"Plaintext auth secret. Only returned to the creator immediately after\nagent creation (one-shot). Subsequent reads return null; use auth_secret_set\nto detect whether a secret is configured."},"allowed_hostnames":{"type":"array","nullable":true,"items":{"type":"string","maxLength":255}},"tools":{"type":"array","description":"Per-tool function-calling definitions with delivery + enabled flag.","items":{"$ref":"#/components/schemas/AgentTool"}}},"required":["language","tts_provider","sts_api_key_set","starts_conversation","session_limit_seconds","max_concurrency","auth_enabled","auth_secret_set","tools"]}]},"AgentBase":{"type":"object","description":"Basic representation of an Agent for list views.","properties":{"agent_id":{"type":"string","format":"uuid","readOnly":true},"organization_id":{"type":"string","format":"uuid","readOnly":true},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when the resource was created via an API key."},"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"status":{"$ref":"#/components/schemas/AgentStatus"},"face_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time","readOnly":true},"updated_at":{"type":"string","format":"date-time","readOnly":true}},"required":["agent_id","organization_id","created_by","title","mode","status","face_id","created_at","updated_at"]},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."},"AgentTool":{"type":"object","additionalProperties":false,"description":"A function-calling tool exposed by the agent to the LLM, with per-tool delivery and enabled flag.","properties":{"tool_id":{"type":"string","format":"uuid","readOnly":true},"enabled":{"type":"boolean","default":true,"description":"When false, the tool is hidden from the LLM but its delivery config is preserved."},"type":{"type":"string","enum":["function"],"default":"function"},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["tool_id","enabled","type","function","delivery"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents/{agent_id}":{"patch":{"tags":["Agents"],"summary":"Update an Agent (partial)","operationId":"updateAgent","parameters":[{"$ref":"#/components/parameters/AgentIdPathParameter"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentRequest"}}}},"responses":{"200":{"description":"Agent updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentDetail"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## Delete an Agent

> Deletes an Agent. Fails with 409 if there are active sessions for the agent.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AgentIdPathParameter":{"name":"agent_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Agent.","schema":{"type":"string","format":"uuid"}}},"responses":{"NoContentSuccess":{"description":"Operation successful, no content to return."},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"ConflictError":{"description":"Conflict with the current state of the resource.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"schemas":{"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}}},"paths":{"/agents/{agent_id}":{"delete":{"tags":["Agents"],"summary":"Delete an Agent","description":"Deletes an Agent. Fails with 409 if there are active sessions for the agent.","operationId":"deleteAgent","parameters":[{"$ref":"#/components/parameters/AgentIdPathParameter"}],"responses":{"204":{"$ref":"#/components/responses/NoContentSuccess"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"404":{"$ref":"#/components/responses/NotFoundError"},"409":{"$ref":"#/components/responses/ConflictError"}}}}}}
```

## PATCH /agents/{agent\_id}/status

> Publish or unpublish an Agent

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AgentIdPathParameter":{"name":"agent_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Agent.","schema":{"type":"string","format":"uuid"}}},"schemas":{"UpdateAgentStatusRequest":{"type":"object","description":"Payload for toggling Agent publication status.","properties":{"status":{"$ref":"#/components/schemas/AgentStatus"}},"required":["status"]},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."},"AgentDetail":{"allOf":[{"$ref":"#/components/schemas/AgentBase"},{"type":"object","description":"Full Agent detail including configuration, STS credentials flags, LLM tool wiring.","properties":{"language":{"$ref":"#/components/schemas/AgentLanguage"},"system_prompt":{"type":"string","maxLength":10000,"nullable":true},"tts_provider":{"type":"string"},"voice_id":{"type":"string","maxLength":255,"nullable":true},"behaviour":{"allOf":[{"$ref":"#/components/schemas/AgentBehaviour"}],"nullable":true},"advanced":{"allOf":[{"$ref":"#/components/schemas/AgentAdvanced"}],"nullable":true},"sts_provider":{"type":"string","maxLength":100,"nullable":true},"sts_config_id":{"type":"string","maxLength":255,"nullable":true},"sts_api_key_set":{"type":"boolean","description":"True when an encrypted STS API key is persisted for this agent. The raw key is never returned."},"provider_config":{"type":"object","additionalProperties":true,"nullable":true},"preview_url":{"type":"string","maxLength":2048,"nullable":true,"description":"A direct URL or a bare asset-ID UUID. The public agent endpoint resolves UUIDs to signed S3 URLs before returning them to clients."},"starts_conversation":{"type":"boolean"},"session_limit_seconds":{"type":"integer","minimum":1,"maximum":86400},"max_concurrency":{"oneOf":[{"type":"integer","enum":[-1]},{"type":"integer","minimum":1,"maximum":100}],"description":"Maximum concurrent sessions. -1 means unbounded (no limit); valid positive values are 1–100."},"auth_enabled":{"type":"boolean"},"auth_secret_set":{"type":"boolean","description":"True when an auth secret is persisted."},"auth_secret":{"type":"string","nullable":true,"description":"Plaintext auth secret. Only returned to the creator immediately after\nagent creation (one-shot). Subsequent reads return null; use auth_secret_set\nto detect whether a secret is configured."},"allowed_hostnames":{"type":"array","nullable":true,"items":{"type":"string","maxLength":255}},"tools":{"type":"array","description":"Per-tool function-calling definitions with delivery + enabled flag.","items":{"$ref":"#/components/schemas/AgentTool"}}},"required":["language","tts_provider","sts_api_key_set","starts_conversation","session_limit_seconds","max_concurrency","auth_enabled","auth_secret_set","tools"]}]},"AgentBase":{"type":"object","description":"Basic representation of an Agent for list views.","properties":{"agent_id":{"type":"string","format":"uuid","readOnly":true},"organization_id":{"type":"string","format":"uuid","readOnly":true},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when the resource was created via an API key."},"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"status":{"$ref":"#/components/schemas/AgentStatus"},"face_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time","readOnly":true},"updated_at":{"type":"string","format":"date-time","readOnly":true}},"required":["agent_id","organization_id","created_by","title","mode","status","face_id","created_at","updated_at"]},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentLanguage":{"type":"string","enum":["en","de","fr","es","ar","ja"],"description":"Spoken language code for the agent. Drives Deepgram STT language,\nElevenLabs voice filtering in the dashboard, and an LLM\nsystem-prompt suffix that pins the response language.\n"},"AgentBehaviour":{"type":"object","description":"Conversational behaviour knobs applied to an Agent.","properties":{"greeting":{"type":"string","maxLength":2000,"nullable":true},"greeting_reference":{"type":"string","maxLength":2000,"nullable":true},"greeting_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"nudge_message":{"type":"string","maxLength":2000,"nullable":true},"nudge_reference":{"type":"string","maxLength":2000,"nullable":true},"nudge_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"farewell_message":{"type":"string","maxLength":2000,"nullable":true},"farewell_reference":{"type":"string","maxLength":2000,"nullable":true},"farewell_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"max_conversation_duration_seconds":{"type":"integer","minimum":1,"maximum":86400,"nullable":true},"inactivity_nudge_seconds":{"type":"integer","minimum":1,"maximum":3600,"nullable":true},"allow_interruption":{"type":"boolean","nullable":true}}},"AgentAdvanced":{"type":"object","description":"Advanced configuration for an Agent (video pipeline + LLM tuning).","properties":{"max_video_source_dimension":{"type":"integer","minimum":1,"nullable":true},"llm_max_tokens":{"type":"integer","minimum":64,"maximum":4096,"nullable":true,"description":"Maximum completion tokens the managed (Ojin-mode) LLM may generate per spoken reply. Raise it if answers get cut off mid-sentence; lower it to keep replies short. Defaults to 384 when unset."}}},"AgentTool":{"type":"object","additionalProperties":false,"description":"A function-calling tool exposed by the agent to the LLM, with per-tool delivery and enabled flag.","properties":{"tool_id":{"type":"string","format":"uuid","readOnly":true},"enabled":{"type":"boolean","default":true,"description":"When false, the tool is hidden from the LLM but its delivery config is preserved."},"type":{"type":"string","enum":["function"],"default":"function"},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["tool_id","enabled","type","function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents/{agent_id}/status":{"patch":{"tags":["Agents"],"summary":"Publish or unpublish an Agent","operationId":"updateAgentStatus","parameters":[{"$ref":"#/components/parameters/AgentIdPathParameter"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentStatusRequest"}}}},"responses":{"200":{"description":"Agent status updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentDetail"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## GET /agents/{agent\_id}/calls

> List call history for an Agent

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AgentIdPathParameter":{"name":"agent_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Agent.","schema":{"type":"string","format":"uuid"}},"LimitQueryParameter":{"name":"limit","in":"query","required":false,"description":"Number of items to return per page.","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},"OffsetQueryParameter":{"name":"offset","in":"query","required":false,"description":"Number of items to skip for pagination.","schema":{"type":"integer","default":0,"minimum":0}}},"schemas":{"CallRecord":{"type":"object","description":"A single historical call for an Agent (one call = one agent session).","properties":{"call_id":{"type":"string","format":"uuid"},"agent_id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"session_id":{"type":"string","format":"uuid","nullable":true},"applied_session_limit_seconds":{"type":"integer"},"client_user_ref":{"type":"string","nullable":true},"started_at":{"type":"string","format":"date-time"},"ended_at":{"type":"string","format":"date-time","nullable":true},"status":{"type":"string","description":"e.g. 'active', 'completed', 'failed'"},"termination_reason":{"type":"string","nullable":true},"failure_reason":{"type":"string","nullable":true},"participant_count":{"type":"integer"},"metadata":{"type":"object","additionalProperties":true,"nullable":true}},"required":["call_id","agent_id","organization_id","applied_session_limit_seconds","started_at","status","participant_count"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents/{agent_id}/calls":{"get":{"tags":["Agents"],"summary":"List call history for an Agent","operationId":"listAgentCalls","parameters":[{"$ref":"#/components/parameters/AgentIdPathParameter"},{"$ref":"#/components/parameters/LimitQueryParameter"},{"$ref":"#/components/parameters/OffsetQueryParameter"},{"name":"started_after","in":"query","required":false,"description":"ISO-8601 date/datetime lower bound on `started_at`.","schema":{"type":"string","format":"date-time"}},{"name":"started_before","in":"query","required":false,"description":"ISO-8601 date/datetime upper bound on `started_at`.","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Paginated call history.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CallRecord"}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total_items":{"type":"integer"}},"required":["limit","offset","total_items"]}},"required":["data","pagination"]}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"},"404":{"$ref":"#/components/responses/NotFoundError"}}}}}}
```

## List STS providers

> Lists available STS (speech-to-speech) providers and the fields each expects.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"schemas":{"AgentProvider":{"type":"object","description":"STS provider descriptor (returned by GET /agents/providers).","properties":{"slug":{"type":"string"},"label":{"type":"string"},"required_persisted":{"type":"array","items":{"type":"string"},"description":"Provider-specific fields that must be persisted in provider_config."},"optional_persisted":{"type":"array","items":{"type":"string"},"description":"Provider-specific fields that may be persisted in provider_config."},"session_fields":{"type":"array","items":{"type":"string"},"description":"Provider-specific fields that must be supplied per-session."},"status":{"type":"string"}},"required":["slug","label","required_persisted","optional_persisted","session_fields","status"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents/providers":{"get":{"tags":["Agents"],"summary":"List STS providers","description":"Lists available STS (speech-to-speech) providers and the fields each expects.","operationId":"listAgentProviders","responses":{"200":{"description":"Provider catalogue.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentProvider"}}}}},"401":{"$ref":"#/components/responses/UnauthorizedError"}}}}}}
```

## Verify third-party provider credentials for an Agent

> Makes a live API call to the agent's configured STS provider using the stored\
> API key and config ID. Returns whether the credentials are valid and any error\
> detail. Does not modify the agent.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Agents","description":"Operations related to Agents (Agent Service v1) — configuration, providers, and call history."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[{"APIKeyAuth":[]}],"components":{"securitySchemes":{"APIKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for authenticated access to the Ojin REST API."}},"parameters":{"AgentIdPathParameter":{"name":"agent_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Agent.","schema":{"type":"string","format":"uuid"}}},"schemas":{"VerifyProviderResult":{"type":"object","description":"Result of a live credential verification check against a third-party STS provider.","properties":{"valid":{"type":"boolean"},"provider":{"type":"string"},"error":{"type":"string","description":"Human-readable reason when valid is false."}},"required":["valid","provider"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"BadRequestError":{"description":"Invalid request payload or parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"UnauthorizedError":{"description":"Authentication token is missing, invalid, or expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"BadGatewayError":{"description":"Upstream service (e.g. WebRTC room provider, agent orchestrator) returned an error or was unreachable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"paths":{"/agents/{agent_id}/verify-provider":{"post":{"tags":["Agents"],"summary":"Verify third-party provider credentials for an Agent","description":"Makes a live API call to the agent's configured STS provider using the stored\nAPI key and config ID. Returns whether the credentials are valid and any error\ndetail. Does not modify the agent.","operationId":"verifyAgentProvider","parameters":[{"$ref":"#/components/parameters/AgentIdPathParameter"}],"responses":{"200":{"description":"Verification result (may be valid or invalid).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyProviderResult"}}}},"400":{"$ref":"#/components/responses/BadRequestError"},"401":{"$ref":"#/components/responses/UnauthorizedError"},"404":{"$ref":"#/components/responses/NotFoundError"},"502":{"$ref":"#/components/responses/BadGatewayError"}}}}}}
```

## Public Widget Endpoints

These unauthenticated endpoints are intended for browser clients such as the Ojin widget.

## Retrieve a Model Configuration (Public)

> Retrieves a specific Model Configuration by ID. This is the public variant of\
> \`GET /model-configs/{model\_config\_id}\`, designed for use by the Ojin widget\
> embedded on third-party domains. Uses permissive CORS and requires no authentication.\
> Resources are accessed by their UUID.\
> \
> Internal fields (organization\_id, created\_by, model\_id) are stripped from the response. The widget chains through \`\`model\_variant\_id\`\` and calls \`\`GET /public/model-variants/{model\_variant\_id}\`\` for catalog metadata (rendered frame size, etc.).

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Public API","description":"Public endpoints for unauthenticated browser clients such as the Ojin widget. These routes use permissive CORS (origin: '*')\nto allow the widget to be embedded on any third-party domain. No authentication is required."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[],"paths":{"/public/model-configs/{model_config_id}":{"get":{"tags":["Public API"],"summary":"Retrieve a Model Configuration (Public)","description":"Retrieves a specific Model Configuration by ID. This is the public variant of\n`GET /model-configs/{model_config_id}`, designed for use by the Ojin widget\nembedded on third-party domains. Uses permissive CORS and requires no authentication.\nResources are accessed by their UUID.\n\nInternal fields (organization_id, created_by, model_id) are stripped from the response. The widget chains through ``model_variant_id`` and calls ``GET /public/model-variants/{model_variant_id}`` for catalog metadata (rendered frame size, etc.).","operationId":"publicGetModelConfigById","parameters":[{"$ref":"#/components/parameters/ModelConfigIdPathParameter"}],"responses":{"200":{"description":"Model Configuration details (without internal fields).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicModelConfig"}}}},"404":{"$ref":"#/components/responses/NotFoundError"}}}}},"components":{"parameters":{"ModelConfigIdPathParameter":{"name":"model_config_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Model Configuration.","schema":{"type":"string","format":"uuid"}}},"schemas":{"PublicModelConfig":{"type":"object","description":"Public variant of ModelConfig for widget embedding.\nStrips internal fields (organization_id, created_by, model_id) — the widget\nchains through ``model_variant_id`` and calls ``GET /public/model-variants/{model_variant_id}``\nwhen it needs the parent ``Model`` reference.","properties":{"model_config_id":{"type":"string","format":"uuid","readOnly":true,"description":"Server-generated unique ID."},"model_variant_id":{"type":"string","description":"ID of the ModelVariant being configured."},"title":{"type":"string","description":"Title for the configuration."},"model_configurations":{"type":"object","additionalProperties":true,"description":"Parameters for the model variant.","default":{}},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update."}},"required":["model_config_id","model_variant_id","title","model_configurations","created_at","updated_at"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}
```

## Retrieve Asset Metadata with Download URL (Public)

> Retrieves metadata for a specific Asset, including a pre-signed download URL.\
> This is the public variant of \`GET /assets/{asset\_id}/download\`, designed for use by the\
> Ojin widget embedded on third-party domains. Uses permissive CORS and requires\
> no authentication. Resources are accessed by their UUID.\
> \
> Internal fields (organization\_id, created\_by) are stripped from the response.

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"tags":[{"name":"Public API","description":"Public endpoints for unauthenticated browser clients such as the Ojin widget. These routes use permissive CORS (origin: '*')\nto allow the widget to be embedded on any third-party domain. No authentication is required."}],"servers":[{"url":"https://api.ojin.ai/v1","description":"Main backend server, version 1."}],"security":[],"paths":{"/public/assets/{asset_id}":{"get":{"tags":["Public API"],"summary":"Retrieve Asset Metadata with Download URL (Public)","description":"Retrieves metadata for a specific Asset, including a pre-signed download URL.\nThis is the public variant of `GET /assets/{asset_id}/download`, designed for use by the\nOjin widget embedded on third-party domains. Uses permissive CORS and requires\nno authentication. Resources are accessed by their UUID.\n\nInternal fields (organization_id, created_by) are stripped from the response.","operationId":"publicGetAssetById","parameters":[{"$ref":"#/components/parameters/AssetIdPathParameter"}],"responses":{"200":{"description":"Successfully retrieved Asset metadata with download URL (without internal fields).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAsset"}}}},"404":{"$ref":"#/components/responses/NotFoundError"}}}}},"components":{"parameters":{"AssetIdPathParameter":{"name":"asset_id","in":"path","required":true,"description":"The unique identifier (UUID) of the Asset.","schema":{"type":"string","format":"uuid"}}},"schemas":{"PublicAsset":{"type":"object","description":"Public variant of Asset for widget embedding.\nStrips internal fields (organization_id, created_by) and includes a pre-signed download URL.","properties":{"asset_id":{"type":"string","format":"uuid","readOnly":true,"description":"Unique identifier for the Asset."},"name":{"type":"string","description":"The original file name of the Asset."},"category":{"type":"string","description":"Primary category of the Asset (e.g., 'video', 'image', 'weight')."},"content_type":{"type":"string","description":"The MIME type of the asset."},"size_bytes":{"type":"integer","format":"int64","description":"The size of the asset in bytes."},"etag":{"type":"string","description":"The ETag of the S3 object."},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update."},"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}},"required":["asset_id","name","category","content_type","size_bytes","etag","created_at","updated_at","asset_url"]},"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}},"responses":{"NotFoundError":{"description":"The requested resource was not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}
```

## Schemas

## The ModelVariant object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"ModelVariant":{"type":"object","description":"Represents a specific variant of a Model.","properties":{"model_variant_id":{"type":"string","description":"Client-provided unique identifier (e.g., \"ojin/oris-v1/standard\")."},"model_id":{"type":"string","description":"Identifier of the parent Model. NOT NULL."},"title":{"type":"string","description":"Display name for the variant. NOT NULL, unique per model_id."},"description":{"type":"object","additionalProperties":{"type":"string"},"description":"UI display texts. NOT NULL, defaults to '{}'."},"preview_media_url":{"type":"string","format":"url","nullable":true,"description":"URL for a preview media."},"configuration_schema":{"type":"object","description":"JSON schema for ModelConfig.model_configurations. NOT NULL, defaults to '{}'.","additionalProperties":true},"tags":{"type":"array","items":{"type":"string"},"description":"List of descriptive tags. NOT NULL, defaults to '[]'."},"status":{"type":"string","description":"Status (e.g., 'available', 'deprecated'). NOT NULL. List managed in code."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of creation. Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of last update. Server-generated. Auto-updates.","readOnly":true}},"required":["model_variant_id","model_id","title","description","configuration_schema","tags","status","created_at","updated_at"]}}}}
```

## The ModelConfig object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"ModelConfig":{"type":"object","description":"Represents a specific configuration of a ModelVariant.","properties":{"model_config_id":{"type":"string","format":"uuid","readOnly":true,"description":"Server-generated unique ID."},"organization_id":{"type":"string","readOnly":true,"description":"Owning organization ID (from external IdP)."},"model_id":{"type":"string","readOnly":true,"description":"ID of the parent Model, derived from the Model Variant."},"model_variant_id":{"type":"string","description":"ID of the ModelVariant being configured. NOT NULL."},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when created via an API key."},"title":{"type":"string","description":"Title for the configuration. NOT NULL, unique per organization_id."},"model_configurations":{"type":"object","additionalProperties":true,"description":"Parameters for the model variant, adhering to its schema. NOT NULL, defaults to '{}'.","default":{}},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation. Defaults to CURRENT_TIMESTAMP."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update. Auto-updates."}},"required":["model_config_id","organization_id","model_id","model_variant_id","created_by","title","model_configurations","created_at","updated_at"]}}}}
```

## The ModelConfigCreationRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"ModelConfigCreationRequest":{"type":"object","description":"Payload for creating a new Model Configuration.","properties":{"title":{"type":"string"},"model_variant_id":{"type":"string"},"model_configurations":{"type":"object","additionalProperties":true,"default":{}}},"required":["title","model_variant_id"]}}}}
```

## The ModelConfigUpdateRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"ModelConfigUpdateRequest":{"type":"object","description":"Payload for updating a Model Configuration (title and parameters only).","properties":{"title":{"type":"string"},"model_configurations":{"type":"object","additionalProperties":true}},"required":["title","model_configurations"]}}}}
```

## The Pagination object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"Pagination":{"type":"object","properties":{"limit":{"type":"integer","description":"The number of items returned in the current page."},"offset":{"type":"integer","description":"The number of items skipped before starting the current page."},"total_items":{"type":"integer","description":"The total number of items available that match the query."}},"required":["limit","offset","total_items"]}}}}
```

## The Asset object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"Asset":{"type":"object","description":"Represents a digital asset managed by the Core API.","properties":{"asset_id":{"type":"string","format":"uuid","description":"Unique identifier for the Asset, generated during upload initiation.","readOnly":true},"organization_id":{"type":"string","description":"ID of the Organisation (from external IdP) that owns this Asset. Server-set.","readOnly":true},"created_by":{"type":"string","description":"User ID of the creator (from external IdP), or `api_key:<id>` when created via an API key. Server-set.","readOnly":true},"name":{"type":"string","description":"The original file name of the Asset. NOT NULL."},"category":{"type":"string","description":"Primary category of the Asset (e.g., 'video', 'image', 'weight'). NOT NULL. List of values managed in code."},"content_type":{"type":"string","description":"The MIME type of the asset. NOT NULL."},"size_bytes":{"type":"integer","format":"int64","description":"The size of the asset in bytes. NOT NULL."},"etag":{"type":"string","description":"The ETag of the S3 object, used for integrity checking. NOT NULL."},"created_at":{"type":"string","format":"date-time","description":"Timestamp of when this Asset record was created (finalized). Server-generated. Defaults to CURRENT_TIMESTAMP.","readOnly":true},"updated_at":{"type":"string","format":"date-time","description":"Timestamp of the last update to this Asset record. Server-generated. Auto-updates on modification.","readOnly":true},"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}},"required":["asset_id","organization_id","created_by","name","category","content_type","size_bytes","etag","created_at","updated_at"]}}}}
```

## The AssetInitiateUploadRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AssetInitiateUploadRequest":{"type":"object","description":"Payload to initiate a multipart asset upload.","properties":{"name":{"type":"string","description":"Original filename of the asset."},"category":{"type":"string","description":"Category for the asset (e.g., 'video', 'image')."},"content_type":{"type":"string","nullable":true,"description":"Client-declared MIME type of the file."},"size_bytes":{"type":"integer","format":"int64","nullable":true,"description":"Client-declared file size in bytes."}},"required":["name","category"]}}}}
```

## The AssetInitiateUploadResponse object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AssetInitiateUploadResponse":{"type":"object","description":"Response from initiating a multipart asset upload.","properties":{"asset_id":{"type":"string","format":"uuid","description":"A unique ID generated by Core API for this asset transaction."},"upload_id":{"type":"string","description":"The multipart upload ID from S3, used to identify this multipart upload session."},"s3_key":{"type":"string","description":"The S3 key for this asset."}},"required":["asset_id","upload_id"]}}}}
```

## The AssetSignPartRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AssetSignPartRequest":{"type":"object","description":"Payload to get a pre-signed URL for uploading a specific part of a multipart upload.","properties":{"s3_key":{"type":"string","description":"The S3 key for this asset."},"asset_id":{"type":"string","format":"uuid","description":"The asset ID from the initiate upload response."},"upload_id":{"type":"string","description":"The multipart upload ID from the initiate upload response."},"part_number":{"type":"integer","minimum":1,"maximum":10000,"description":"The part number for this upload part (1-10000)."}},"required":["asset_id","upload_id","part_number"]}}}}
```

## The AssetSignPartResponse object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AssetSignPartResponse":{"type":"object","description":"Response containing the pre-signed URL for uploading a specific part.","properties":{"upload_url":{"type":"string","format":"url","description":"The pre-signed S3 URL to PUT this specific part to."},"part_number":{"type":"integer","description":"The part number this URL is for."}},"required":["upload_url","part_number"]}}}}
```

## The AssetFinalizationRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AssetFinalizationRequest":{"type":"object","description":"Payload to finalize a multipart asset upload and create the asset metadata record in DB.","properties":{"asset_id":{"type":"string","format":"uuid","description":"The unique ID received from the 'initiate-upload' step."},"upload_id":{"type":"string","description":"The multipart upload ID from the 'initiate-upload' step."},"name":{"type":"string","description":"The original filename (must be consistent with initiate request)."},"category":{"type":"string","description":"The asset category (must be consistent with initiate request)."},"content_type":{"type":"string","description":"Final confirmed MIME type of the asset."},"size_bytes":{"type":"integer","format":"int64","description":"Final confirmed size of the asset in bytes."},"parts":{"type":"array","items":{"$ref":"#/components/schemas/AssetUploadPart"},"description":"List of all uploaded parts with their ETags, in order.","minItems":1}},"required":["asset_id","upload_id","name","category","content_type","size_bytes","parts"]},"AssetUploadPart":{"type":"object","description":"Information about a completed upload part.","properties":{"part_number":{"type":"integer","minimum":1,"maximum":10000,"description":"The part number that was uploaded."},"etag":{"type":"string","description":"The ETag returned by S3 after successfully uploading this part."}},"required":["part_number","etag"]}}}}
```

## The AssetUploadPart object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AssetUploadPart":{"type":"object","description":"Information about a completed upload part.","properties":{"part_number":{"type":"integer","minimum":1,"maximum":10000,"description":"The part number that was uploaded."},"etag":{"type":"string","description":"The ETag returned by S3 after successfully uploading this part."}},"required":["part_number","etag"]}}}}
```

## The IdleVideoGenerationRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"IdleVideoGenerationRequest":{"type":"object","description":"Request payload to trigger idle video generation.","properties":{"source_asset_id":{"type":"string","format":"uuid","description":"The ID of the source image asset to use for generating the idle video."},"reference_template":{"type":"string","description":"The reference motion template to use (e.g., 'v1', 'v2', 'v3').","enum":["v1","v2","v3"]},"model_variant_id":{"type":"string","maxLength":128,"description":"Model variant the generated idle video is intended for (e.g. 'ojin/oris-portrait'). Used only to label the background job so the Recent Generations list names the right model. Optional; callers without a model context omit it and the job uses a default label."},"target_model_config_id":{"type":"string","format":"uuid","description":"Model config to assign the generated video to as its active idle preview once the job completes. When set, core-api writes model_configurations.preview server-side on completion, so the assignment lands even if the browser closed, navigated away, or refreshed mid-generation. Must be a config owned by the caller's organization. Optional; omit to only create the video without assigning it."}},"required":["source_asset_id"]}}}}
```

## The IdleVideoGenerationResponse object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"IdleVideoGenerationResponse":{"type":"object","description":"Response from triggering idle video generation.","properties":{"job_id":{"type":"string","format":"uuid","description":"Identifier of the background job created for this generation request."}},"required":["job_id"]}}}}
```

## The AgentBase object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentBase":{"type":"object","description":"Basic representation of an Agent for list views.","properties":{"agent_id":{"type":"string","format":"uuid","readOnly":true},"organization_id":{"type":"string","format":"uuid","readOnly":true},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when the resource was created via an API key."},"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"status":{"$ref":"#/components/schemas/AgentStatus"},"face_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time","readOnly":true},"updated_at":{"type":"string","format":"date-time","readOnly":true}},"required":["agent_id","organization_id","created_by","title","mode","status","face_id","created_at","updated_at"]},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."}}}}
```

## The AgentDetail object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentDetail":{"allOf":[{"$ref":"#/components/schemas/AgentBase"},{"type":"object","description":"Full Agent detail including configuration, STS credentials flags, LLM tool wiring.","properties":{"language":{"$ref":"#/components/schemas/AgentLanguage"},"system_prompt":{"type":"string","maxLength":10000,"nullable":true},"tts_provider":{"type":"string"},"voice_id":{"type":"string","maxLength":255,"nullable":true},"behaviour":{"allOf":[{"$ref":"#/components/schemas/AgentBehaviour"}],"nullable":true},"advanced":{"allOf":[{"$ref":"#/components/schemas/AgentAdvanced"}],"nullable":true},"sts_provider":{"type":"string","maxLength":100,"nullable":true},"sts_config_id":{"type":"string","maxLength":255,"nullable":true},"sts_api_key_set":{"type":"boolean","description":"True when an encrypted STS API key is persisted for this agent. The raw key is never returned."},"provider_config":{"type":"object","additionalProperties":true,"nullable":true},"preview_url":{"type":"string","maxLength":2048,"nullable":true,"description":"A direct URL or a bare asset-ID UUID. The public agent endpoint resolves UUIDs to signed S3 URLs before returning them to clients."},"starts_conversation":{"type":"boolean"},"session_limit_seconds":{"type":"integer","minimum":1,"maximum":86400},"max_concurrency":{"oneOf":[{"type":"integer","enum":[-1]},{"type":"integer","minimum":1,"maximum":100}],"description":"Maximum concurrent sessions. -1 means unbounded (no limit); valid positive values are 1–100."},"auth_enabled":{"type":"boolean"},"auth_secret_set":{"type":"boolean","description":"True when an auth secret is persisted."},"auth_secret":{"type":"string","nullable":true,"description":"Plaintext auth secret. Only returned to the creator immediately after\nagent creation (one-shot). Subsequent reads return null; use auth_secret_set\nto detect whether a secret is configured."},"allowed_hostnames":{"type":"array","nullable":true,"items":{"type":"string","maxLength":255}},"tools":{"type":"array","description":"Per-tool function-calling definitions with delivery + enabled flag.","items":{"$ref":"#/components/schemas/AgentTool"}}},"required":["language","tts_provider","sts_api_key_set","starts_conversation","session_limit_seconds","max_concurrency","auth_enabled","auth_secret_set","tools"]}]},"AgentBase":{"type":"object","description":"Basic representation of an Agent for list views.","properties":{"agent_id":{"type":"string","format":"uuid","readOnly":true},"organization_id":{"type":"string","format":"uuid","readOnly":true},"created_by":{"type":"string","readOnly":true,"description":"Creator's user ID (from external IdP), or `api_key:<id>` when the resource was created via an API key."},"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"status":{"$ref":"#/components/schemas/AgentStatus"},"face_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time","readOnly":true},"updated_at":{"type":"string","format":"date-time","readOnly":true}},"required":["agent_id","organization_id","created_by","title","mode","status","face_id","created_at","updated_at"]},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."},"AgentLanguage":{"type":"string","enum":["en","de","fr","es","ar","ja"],"description":"Spoken language code for the agent. Drives Deepgram STT language,\nElevenLabs voice filtering in the dashboard, and an LLM\nsystem-prompt suffix that pins the response language.\n"},"AgentBehaviour":{"type":"object","description":"Conversational behaviour knobs applied to an Agent.","properties":{"greeting":{"type":"string","maxLength":2000,"nullable":true},"greeting_reference":{"type":"string","maxLength":2000,"nullable":true},"greeting_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"nudge_message":{"type":"string","maxLength":2000,"nullable":true},"nudge_reference":{"type":"string","maxLength":2000,"nullable":true},"nudge_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"farewell_message":{"type":"string","maxLength":2000,"nullable":true},"farewell_reference":{"type":"string","maxLength":2000,"nullable":true},"farewell_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"max_conversation_duration_seconds":{"type":"integer","minimum":1,"maximum":86400,"nullable":true},"inactivity_nudge_seconds":{"type":"integer","minimum":1,"maximum":3600,"nullable":true},"allow_interruption":{"type":"boolean","nullable":true}}},"AgentAdvanced":{"type":"object","description":"Advanced configuration for an Agent (video pipeline + LLM tuning).","properties":{"max_video_source_dimension":{"type":"integer","minimum":1,"nullable":true},"llm_max_tokens":{"type":"integer","minimum":64,"maximum":4096,"nullable":true,"description":"Maximum completion tokens the managed (Ojin-mode) LLM may generate per spoken reply. Raise it if answers get cut off mid-sentence; lower it to keep replies short. Defaults to 384 when unset."}}},"AgentTool":{"type":"object","additionalProperties":false,"description":"A function-calling tool exposed by the agent to the LLM, with per-tool delivery and enabled flag.","properties":{"tool_id":{"type":"string","format":"uuid","readOnly":true},"enabled":{"type":"boolean","default":true,"description":"When false, the tool is hidden from the LLM but its delivery config is preserved."},"type":{"type":"string","enum":["function"],"default":"function"},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["tool_id","enabled","type","function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]}}}}
```

## The CreateAgentRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"CreateAgentRequest":{"type":"object","description":"Payload for creating a new Agent, optionally from a preset.","properties":{"title":{"type":"string","maxLength":255},"mode":{"$ref":"#/components/schemas/AgentMode"},"preset_id":{"type":"string","format":"uuid","description":"Optional preset to seed the new agent from."}},"required":["title"]},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."}}}}
```

## The UpdateAgentRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"UpdateAgentRequest":{"type":"object","description":"Payload for updating an Agent (partial update — all fields optional).","properties":{"title":{"type":"string","maxLength":255},"description":{"type":"string","maxLength":1000,"nullable":true},"mode":{"$ref":"#/components/schemas/AgentMode"},"face_id":{"type":"string","format":"uuid","nullable":true},"language":{"$ref":"#/components/schemas/AgentLanguage"},"system_prompt":{"type":"string","maxLength":10000,"nullable":true},"tts_provider":{"type":"string","enum":["elevenlabs","ojin"]},"voice_id":{"type":"string","maxLength":255,"nullable":true},"behaviour":{"$ref":"#/components/schemas/AgentBehaviour"},"advanced":{"$ref":"#/components/schemas/AgentAdvanced"},"sts_provider":{"type":"string","maxLength":100,"nullable":true},"sts_api_key":{"type":"string","maxLength":1000,"nullable":true,"writeOnly":true},"sts_config_id":{"type":"string","maxLength":255,"nullable":true},"provider_config":{"type":"object","additionalProperties":true,"nullable":true},"preview_url":{"type":"string","maxLength":2048,"nullable":true,"description":"A direct URL or a bare asset-ID UUID. The public agent endpoint resolves UUIDs to signed S3 URLs before returning them to clients."},"session_limit_seconds":{"type":"integer","minimum":1,"maximum":86400},"max_concurrency":{"oneOf":[{"type":"integer","enum":[-1]},{"type":"integer","minimum":1,"maximum":100}],"description":"Maximum concurrent sessions. -1 means unbounded (no limit); valid positive values are 1–100."},"starts_conversation":{"type":"boolean"},"auth_enabled":{"type":"boolean"},"allowed_hostnames":{"type":"array","items":{"type":"string","maxLength":255}},"tools":{"type":"array","description":"Replace the agent's tools with this array (full-replace semantics).\nOmit to leave tools unchanged. UPSERTs by `function.name`; tools not\npresent in the array are deleted from the agent.","items":{"$ref":"#/components/schemas/AgentToolInput"}}}},"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."},"AgentLanguage":{"type":"string","enum":["en","de","fr","es","ar","ja"],"description":"Spoken language code for the agent. Drives Deepgram STT language,\nElevenLabs voice filtering in the dashboard, and an LLM\nsystem-prompt suffix that pins the response language.\n"},"AgentBehaviour":{"type":"object","description":"Conversational behaviour knobs applied to an Agent.","properties":{"greeting":{"type":"string","maxLength":2000,"nullable":true},"greeting_reference":{"type":"string","maxLength":2000,"nullable":true},"greeting_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"nudge_message":{"type":"string","maxLength":2000,"nullable":true},"nudge_reference":{"type":"string","maxLength":2000,"nullable":true},"nudge_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"farewell_message":{"type":"string","maxLength":2000,"nullable":true},"farewell_reference":{"type":"string","maxLength":2000,"nullable":true},"farewell_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"max_conversation_duration_seconds":{"type":"integer","minimum":1,"maximum":86400,"nullable":true},"inactivity_nudge_seconds":{"type":"integer","minimum":1,"maximum":3600,"nullable":true},"allow_interruption":{"type":"boolean","nullable":true}}},"AgentAdvanced":{"type":"object","description":"Advanced configuration for an Agent (video pipeline + LLM tuning).","properties":{"max_video_source_dimension":{"type":"integer","minimum":1,"nullable":true},"llm_max_tokens":{"type":"integer","minimum":64,"maximum":4096,"nullable":true,"description":"Maximum completion tokens the managed (Ojin-mode) LLM may generate per spoken reply. Raise it if answers get cut off mid-sentence; lower it to keep replies short. Defaults to 384 when unset."}}},"AgentToolInput":{"type":"object","additionalProperties":false,"description":"Input shape for an agent tool. `tool_id` is optional — when present and\nmatched (by `function.name`) the existing row is updated in place; when\nabsent or unmatched a new tool is created. `enabled` defaults to true\nand `type` defaults to \"function\" server-side when omitted.","properties":{"tool_id":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"},"type":{"type":"string","enum":["function"]},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]}}}}
```

## The UpdateAgentStatusRequest object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"UpdateAgentStatusRequest":{"type":"object","description":"Payload for toggling Agent publication status.","properties":{"status":{"$ref":"#/components/schemas/AgentStatus"}},"required":["status"]},"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."}}}}
```

## The AgentProvider object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentProvider":{"type":"object","description":"STS provider descriptor (returned by GET /agents/providers).","properties":{"slug":{"type":"string"},"label":{"type":"string"},"required_persisted":{"type":"array","items":{"type":"string"},"description":"Provider-specific fields that must be persisted in provider_config."},"optional_persisted":{"type":"array","items":{"type":"string"},"description":"Provider-specific fields that may be persisted in provider_config."},"session_fields":{"type":"array","items":{"type":"string"},"description":"Provider-specific fields that must be supplied per-session."},"status":{"type":"string"}},"required":["slug","label","required_persisted","optional_persisted","session_fields","status"]}}}}
```

## The CallRecord object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"CallRecord":{"type":"object","description":"A single historical call for an Agent (one call = one agent session).","properties":{"call_id":{"type":"string","format":"uuid"},"agent_id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"session_id":{"type":"string","format":"uuid","nullable":true},"applied_session_limit_seconds":{"type":"integer"},"client_user_ref":{"type":"string","nullable":true},"started_at":{"type":"string","format":"date-time"},"ended_at":{"type":"string","format":"date-time","nullable":true},"status":{"type":"string","description":"e.g. 'active', 'completed', 'failed'"},"termination_reason":{"type":"string","nullable":true},"failure_reason":{"type":"string","nullable":true},"participant_count":{"type":"integer"},"metadata":{"type":"object","additionalProperties":true,"nullable":true}},"required":["call_id","agent_id","organization_id","applied_session_limit_seconds","started_at","status","participant_count"]}}}}
```

## The VerifyProviderResult object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"VerifyProviderResult":{"type":"object","description":"Result of a live credential verification check against a third-party STS provider.","properties":{"valid":{"type":"boolean"},"provider":{"type":"string"},"error":{"type":"string","description":"Human-readable reason when valid is false."}},"required":["valid","provider"]}}}}
```

## The AgentMode object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentMode":{"type":"string","enum":["ojin","third_party"],"description":"The operating mode of an agent. Ojin mode uses the built-in pipeline; third_party mode delegates to an external STS provider."}}}}
```

## The AgentStatus object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentStatus":{"type":"string","enum":["published","unpublished"],"description":"The publication status of an agent. Published agents are available for connections; unpublished agents are not."}}}}
```

## The AgentLanguage object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentLanguage":{"type":"string","enum":["en","de","fr","es","ar","ja"],"description":"Spoken language code for the agent. Drives Deepgram STT language,\nElevenLabs voice filtering in the dashboard, and an LLM\nsystem-prompt suffix that pins the response language.\n"}}}}
```

## The AgentBehaviour object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentBehaviour":{"type":"object","description":"Conversational behaviour knobs applied to an Agent.","properties":{"greeting":{"type":"string","maxLength":2000,"nullable":true},"greeting_reference":{"type":"string","maxLength":2000,"nullable":true},"greeting_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"nudge_message":{"type":"string","maxLength":2000,"nullable":true},"nudge_reference":{"type":"string","maxLength":2000,"nullable":true},"nudge_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"farewell_message":{"type":"string","maxLength":2000,"nullable":true},"farewell_reference":{"type":"string","maxLength":2000,"nullable":true},"farewell_mode":{"type":"string","enum":["verbatim","composed"],"nullable":true},"max_conversation_duration_seconds":{"type":"integer","minimum":1,"maximum":86400,"nullable":true},"inactivity_nudge_seconds":{"type":"integer","minimum":1,"maximum":3600,"nullable":true},"allow_interruption":{"type":"boolean","nullable":true}}}}}}
```

## The AgentAdvanced object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentAdvanced":{"type":"object","description":"Advanced configuration for an Agent (video pipeline + LLM tuning).","properties":{"max_video_source_dimension":{"type":"integer","minimum":1,"nullable":true},"llm_max_tokens":{"type":"integer","minimum":64,"maximum":4096,"nullable":true,"description":"Maximum completion tokens the managed (Ojin-mode) LLM may generate per spoken reply. Raise it if answers get cut off mid-sentence; lower it to keep replies short. Defaults to 384 when unset."}}}}}}
```

## The AgentTool object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentTool":{"type":"object","additionalProperties":false,"description":"A function-calling tool exposed by the agent to the LLM, with per-tool delivery and enabled flag.","properties":{"tool_id":{"type":"string","format":"uuid","readOnly":true},"enabled":{"type":"boolean","default":true,"description":"When false, the tool is hidden from the LLM but its delivery config is preserved."},"type":{"type":"string","enum":["function"],"default":"function"},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["tool_id","enabled","type","function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]}}}}
```

## The AgentToolInput object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"AgentToolInput":{"type":"object","additionalProperties":false,"description":"Input shape for an agent tool. `tool_id` is optional — when present and\nmatched (by `function.name`) the existing row is updated in place; when\nabsent or unmatched a new tool is created. `enabled` defaults to true\nand `type` defaults to \"function\" server-side when omitted.","properties":{"tool_id":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"},"type":{"type":"string","enum":["function"]},"function":{"$ref":"#/components/schemas/ToolFunction"},"delivery":{"$ref":"#/components/schemas/ToolDelivery"}},"required":["function","delivery"]},"ToolFunction":{"type":"object","additionalProperties":false,"description":"OpenAI / Anthropic tool function definition.","properties":{"name":{"type":"string","maxLength":64,"pattern":"^[a-zA-Z0-9_-]+$","description":"Function name. Must match the LLM tool-name regex."},"description":{"type":"string","maxLength":1024,"nullable":true},"parameters":{"type":"object","additionalProperties":true,"nullable":true,"description":"JSON Schema for the function's parameters (opaque pass-through)."}},"required":["name"]},"ToolDelivery":{"oneOf":[{"$ref":"#/components/schemas/ClientToolDelivery"},{"$ref":"#/components/schemas/WebhookToolDelivery"}],"discriminator":{"propertyName":"mode","mapping":{"client":"#/components/schemas/ClientToolDelivery","webhook":"#/components/schemas/WebhookToolDelivery"}}},"ClientToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are delivered to the in-browser widget as an `ojinToolCall` CustomEvent.","properties":{"mode":{"type":"string","enum":["client"]}},"required":["mode"]},"WebhookToolDelivery":{"type":"object","additionalProperties":false,"description":"Tool calls are POSTed to an external webhook URL.","properties":{"mode":{"type":"string","enum":["webhook"]},"url":{"type":"string","format":"uri","maxLength":2048},"wait_for_response":{"type":"boolean","default":false,"description":"When true, the LLM waits for the webhook's response and incorporates it\ninto the conversation. When false, the webhook is fired-and-forgotten and\nthe LLM receives a neutral acknowledgement immediately."}},"required":["mode","url"]}}}}
```

## The PublicModelConfig object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"PublicModelConfig":{"type":"object","description":"Public variant of ModelConfig for widget embedding.\nStrips internal fields (organization_id, created_by, model_id) — the widget\nchains through ``model_variant_id`` and calls ``GET /public/model-variants/{model_variant_id}``\nwhen it needs the parent ``Model`` reference.","properties":{"model_config_id":{"type":"string","format":"uuid","readOnly":true,"description":"Server-generated unique ID."},"model_variant_id":{"type":"string","description":"ID of the ModelVariant being configured."},"title":{"type":"string","description":"Title for the configuration."},"model_configurations":{"type":"object","additionalProperties":true,"description":"Parameters for the model variant.","default":{}},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update."}},"required":["model_config_id","model_variant_id","title","model_configurations","created_at","updated_at"]}}}}
```

## The PublicAsset object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"PublicAsset":{"type":"object","description":"Public variant of Asset for widget embedding.\nStrips internal fields (organization_id, created_by) and includes a pre-signed download URL.","properties":{"asset_id":{"type":"string","format":"uuid","readOnly":true,"description":"Unique identifier for the Asset."},"name":{"type":"string","description":"The original file name of the Asset."},"category":{"type":"string","description":"Primary category of the Asset (e.g., 'video', 'image', 'weight')."},"content_type":{"type":"string","description":"The MIME type of the asset."},"size_bytes":{"type":"integer","format":"int64","description":"The size of the asset in bytes."},"etag":{"type":"string","description":"The ETag of the S3 object."},"created_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of creation."},"updated_at":{"type":"string","format":"date-time","readOnly":true,"description":"Timestamp of last update."},"asset_url":{"type":"string","format":"uri","readOnly":true,"description":"A temporary, pre-signed URL to download the asset's content. This URL will expire."}},"required":["asset_id","name","category","content_type","size_bytes","etag","created_at","updated_at","asset_url"]}}}}
```

## The ErrorResponse object

```json
{"openapi":"3.0.0","info":{"title":"Ojin REST API","version":"v1.0.0"},"components":{"schemas":{"ErrorResponse":{"type":"object","properties":{"code":{"type":"string","description":"A short, machine-readable error code string."},"message":{"type":"string","description":"A human-readable description of the error."},"details":{"type":"object","additionalProperties":true,"nullable":true,"description":"Optional. Additional structured details about the error."}},"required":["code","message"]}}}}
```


# Support

Need help with the Ojin platform? We're here to assist you with any questions, issues, or feedback you may have.

## Contact Us

For support inquiries, please reach out to our team:

**Email**: <support@ojin.ai>

**Please do not send job applications to this address. We cannot respond to them.**

Our support team will respond to your inquiry as soon as possible.

{% hint style="info" %}
Use this address for support inquiries about the Ojin platform and for data-rights requests, including [account and data deletion](/getting-started/data-deletion). For everything else, please refer to [ojin.ai](https://ojin.ai).
{% endhint %}

## What to Include in Your Support Request

To help us assist you more efficiently, please include the following information when contacting support:

* **Description**: A clear description of your issue or question
* **Model**: Which model you're working with (e.g., `ojin/human-presence`, `ojin/human-portrait`)
* **Error Messages**: Any error messages or codes you're encountering
* **Steps to Reproduce**: If applicable, steps to reproduce the issue
* **Expected vs Actual Behavior**: What you expected to happen vs what actually happened
* **Environment**: Your development environment details (language, framework, etc.)

{% hint style="info" %}
Before reaching out, check the [Troubleshooting guide](/guides/troubleshooting).
{% endhint %}

## Additional Resources

* [Documentation](/)
* [Data Deletion](/getting-started/data-deletion)
* [Quickstart Guide](/getting-started/quickstart)
* [Build with the Python SDK](/models/build-with-python-sdk)
* [Human Presence](/models/human-presence)
* [Human Portrait](/models/human-portrait)


# Data Deletion

You can ask us to delete your account and the data associated with it at any time.

## How to request deletion

From the dashboard, go to **Settings → Account** and use **Delete your account and data**. That sends the request to our team directly.

You can also email <support@ojin.ai> from the address associated with your account.

Either way, **we will contact you to verify the request before anything is deleted.** Deletion is irreversible, so we confirm it is really you asking.

## What gets deleted

Ojin accounts belong to an **organization**, and what happens depends on whether you are its only member. We handle every organization you belong to.

**Where you are the only member of your organization**, the organization and everything in it is deleted: your agents, model configurations, API keys, uploaded assets, session records, and background job history. The organization's billing account is closed. **Your Ojin login itself is deleted once you have no remaining organizations**, if you belong to another one, your login stays so you can keep using it there.

**Where your organization has other members**, your membership and your personal data are removed, and your name is taken off resources you created. The organization itself (its agents, assets and API keys) remains, because those belong to the organization rather than to you. Your colleagues keep working.

## What we have to keep

We cannot delete everything, and we would rather say so than promise otherwise.

Ojin is operated by Journee Technologies GmbH, a German company, and German law (§147 AO / §257 HGB) requires us to retain **invoices and accounting records for ten years**. The GDPR recognises this; the right to erasure does not override a legal retention obligation (Art. 17(3)(b)).

In practice this means your invoices are kept, together with the billing customer record they are attached to, which carries the name and address that appear on them. Our payment provider likewise retains a record of past charges.

We also keep a **record of the deletion request itself**, the email address you contacted us from, and what we did, as the evidence that we handled your request properly. Data-protection rules expect us to be able to show that, so this record outlives the account it refers to.

Anything we retain for these reasons is held under **restricted processing** (GDPR Art. 18): it is kept for that purpose alone and is not used to operate the product, contact you, or anything else.

## How long it takes

We complete deletion requests **within 30 days of receiving them**, and usually sooner. If we need to confirm your identity first, that verification happens inside the same window.

## Questions

Email <support@ojin.ai>.


# Human Agent

Ojin's star product, give your AI agent a face. A lifelike visual avatar that speaks, listens, and expresses emotion with real-time synchronized animation, embedded with a single line of code.

## Overview

The Human Agent is Ojin's complete, ready-to-deploy conversational agent: it combines speech-to-speech (STS) with a lifelike animated face, powered by one of Ojin's face models, [Human Presence](/models/human-presence) or [Human Portrait](/models/human-portrait), into one product. Instead of wiring up STT, LLM, TTS, and avatar services yourself, you create an agent in the Ojin dashboard, embed a single widget on your site, and your users get a live, real-time conversation with a fully animated avatar. It's the fastest way to ship a talking agent, with no pipeline to assemble.

## Key Features

* **Lifelike visual avatar**: your agent has a face with synchronized lip movements and natural expressions
* **End-to-end conversational AI**: speech in, speech out, no pipeline assembly required
* **Real-time**: WebRTC transport for audio, video, and signaling
* **No pipeline assembly required**: Ojin manages the full speech-to-speech stack (or bring your own provider)
* **Drop-in widget**: one HTML tag to embed the agent in any web page
* **Dashboard configuration**: create, configure, and monitor agents without writing code

## Agent Modes

### Ojin Agent (managed)

You configure the personality and appearance, system prompt, face, voice, and behaviour. Ojin handles everything else: the conversational pipeline, avatar rendering, and infrastructure. You never see or manage the underlying providers.

### Third-Party Agent

You bring your own speech-to-speech provider: Hume, ElevenLabs Agents, or Ultravox. Ojin adds the visual avatar layer and runs the agent through Ojin infrastructure. You supply your provider API key and config ID in the dashboard; Ojin handles the rest.

## How It Works

1. **Create an agent** in the [Ojin dashboard](https://ojin.ai/dashboard). Pick a mode, configure it, go online
2. **Get your agent ID** from the agent settings page
3. **Embed the widget** on your site or call the Session API from your backend
4. **Your users interact** via WebRTC: audio, video, and real-time avatar in a single connection

What a session looks like once the agent is live:

```mermaid
sequenceDiagram
    participant U as Your user
    participant C as Widget or your client
    participant S as Session API
    participant A as Ojin agent runtime

    C->>S: POST /v1/public/agents/connect (agent_id)
    S-->>C: session_id, room_url, token
    C->>A: Join the WebRTC room (room_url, token)
    A-->>C: Agent audio + avatar video tracks
    loop Conversation
        U->>A: Speech
        A-->>U: Spoken reply, lip-synced avatar
    end
```

The widget performs every step above for you. Call the [Session API](/apps/overview/api-reference) directly only if you are building a custom client.

## Use Cases

* **Customer Support**: let customers talk to a lifelike agent instead of a chatbot
* **Sales**: greet and qualify leads with a conversational avatar
* **Education**: build interactive tutors with natural speech and expressions
* **Healthcare**: create empathetic virtual health assistants
* **Reception**: deploy a digital receptionist on your website or kiosk

## Pricing

Agent sessions are metered based on usage. Visit [ojin.ai/pricing](https://ojin.ai/pricing) for details on plans and per-session costs.

## Quick Start

1. [**Create an API key**](/getting-started/authentication): set up authentication for the Ojin platform
2. [**Create & configure your agent**](/apps/overview/configure): set up appearance, voice, and behaviour
3. [**Widget Integration**](/apps/overview/widget-integration): drop the agent into your web page
4. [**Session API Reference**](/apps/overview/api-reference): for custom integrations beyond the widget


# Create & Configure

Set up your Human Agent in the Ojin dashboard. Pick a mode, configure appearance and behaviour, and go online.

## Prerequisites

1. An Ojin account with an active API key, [get your API key](/getting-started/authentication)

## Creating an Agent

Head to the [Ojin dashboard](https://ojin.ai/dashboard) and create a new agent. Give it a name and pick your mode:

* **Ojin Agent**: Ojin handles everything: the conversational pipeline and avatar rendering. You just configure the personality and appearance.
* **Third-Party Agent**: you bring your own speech-to-speech provider (Hume, ElevenLabs Agents, or Ultravox). Ojin adds the visual avatar.

### Starting from a Preset

If you choose Ojin Agent mode, you can start from a preset, a ready-made template that pre-fills the face, voice, system prompt, and behaviour for common use cases like Customer Support, Sales Assistant, or Receptionist. You can customize everything after creation.

Alternatively, start blank and configure each field yourself.

## Configuring an Ojin Agent

### System Prompt

Write a prompt that defines how the agent behaves: its personality, knowledge, tone, and any instructions for the conversation. This works the same as a system prompt for any LLM.

### Face

Pick a visual appearance for your agent. The face is powered by Ojin's avatar model [ojin/human-portrait](/models/human-portrait), which generates lifelike personas from a single reference image. Browse the available face configurations in the dashboard and select the one that best fits your agent's personality.

### Captions

Choose the caption treatment shown over the avatar while it speaks:

* **Off**: do not show captions
* **Conversation**: calm captions with active-word emphasis
* **TikTok**: kinetic one-to-four-word beats with active-word emphasis
* **YouTube**: bold, uppercase karaoke captions
* **Arthouse**: restrained cinematic subtitles
* **Journalist**: an editorial caption pill with an active-word underline

The dashboard also lets you choose dark or light text, an accent colour, text size, and no background or a solid background. Arthouse always uses its yellow text without a background. The widget uses the style's matching typeface and animation automatically.

Caption settings belong to the agent creator. Viewers cannot override or turn them off. The widget temporarily hides captions while its chat panel is open. New agents start with captions off until their creator chooses a style.

### Voice

Select a TTS voice for the agent's speech output. The voice determines how the agent sounds when it responds.

### Behaviour

Configure how the agent interacts:

* **Greeting**: the first thing the agent says when a session starts (e.g., "Hi, how can I help you today?")
* **Vocal burst**: a short sound the agent makes while listening (e.g., "mhm", "uh-huh") to signal active listening
* **Nudge**: a message the agent sends if the user stays silent for a while

### Tools (Function Calling)

Give the agent tools it can call during a conversation, to look something up, update your UI, or notify a backend. Add and configure tools here, choosing how each one's calls are delivered (to a webhook or to the embedding page). See [**Tools & Events**](/apps/overview/tools) for the delivery modes and how to handle calls.

### Advanced Settings

Fine-tune the agent's audio processing:

* **VAD stop seconds**: how long to wait after the user stops speaking before the agent responds (default: 0.5s)
* **Min volume**: minimum audio volume threshold to detect speech (default: 0.1)
* **Noise filter**: toggle background noise filtering (default: off)

### Session Limit

Set the maximum duration for a single session in seconds. Default is 600 seconds (10 minutes). When the limit is reached, the session ends automatically.

## Configuring a Third-Party Agent

### Face

Same as Ojin Agent. Pick a visual appearance using Ojin's avatar model [ojin/human-portrait](/models/human-portrait). Regardless of which provider powers the conversation, Ojin renders the avatar.

### Provider

Select your speech-to-speech provider from the dropdown:

* **Hume**: enter your Hume API key. Config ID is optional (Hume can use a default config).
* **ElevenLabs Agents**: enter your ElevenLabs API key and your ElevenLabs agent ID (required).
* **Ultravox**: enter your Ultravox API key. Config ID is optional.

{% hint style="warning" %}
Your provider API key is encrypted at rest and never displayed after you save it. To rotate a key, enter the new one in the dashboard. It takes effect for new sessions immediately.
{% endhint %}

## Going Online

When your agent is configured, toggle its status to **online** in the [dashboard](https://ojin.ai/dashboard). The system validates that all required fields are filled before allowing the agent to go live:

* **Ojin Agent** requires: system prompt, voice, and face
* **Third-Party Agent** requires: provider, API key, and face (plus any provider-specific required fields)

If anything is missing, the dashboard tells you which fields need attention.

## Concurrency

The **max concurrency** setting controls how many simultaneous sessions your agent can handle. Default is 1, meaning one user can talk to the agent at a time. Increase this if you expect multiple concurrent users.

When all slots are in use, new connection attempts receive a `concurrency_limit` error with a `retry_after_seconds` hint.

## Next Steps

* [**Tools & Events**](/apps/overview/tools): give the agent function-calling tools and react to it from your page
* [**Widget Integration**](/apps/overview/widget-integration): add the agent to your web page
* [**Session API Reference**](/apps/overview/api-reference): for custom integrations


# Tools & Events

Let your Human Agent call functions during a conversation, and react to the agent from your own page.

## Tool Calling

Tools (function calling) let the agent's LLM trigger actions while it talks: look something up, submit a form, update your UI, or notify a backend system. You define each tool's name, description, and JSON-schema parameters; the LLM decides when to call it and with what arguments.

Tools are configured **per agent**. You can manage them two ways:

* In the [Ojin dashboard](https://ojin.ai/dashboard) when [configuring an agent](/apps/overview/configure), the agent's tools editor.
* Programmatically via the [REST API](#managing-tools-via-the-rest-api).

Each tool has an **enabled** flag. A disabled tool keeps its configuration but is hidden from the LLM, so you can turn a tool off without losing its setup.

## Delivery Modes

Every tool declares how its calls are delivered. There is no agent-wide default. Each tool picks its own mode.

| Mode        | Where the call goes                                     | Your responsibility                        |
| ----------- | ------------------------------------------------------- | ------------------------------------------ |
| **Webhook** | Ojin POSTs the call to a URL you configure, server-side | Stand up the endpoint; no browser code     |
| **Client**  | The widget emits a DOM event in the user's browser      | Listen for the event on the embedding page |

Use **webhook** delivery for backend actions (write to a database, call an API, send a notification). Use **client** delivery for things that happen in the user's browser (navigate the page, open a modal, update on-page state).

## Webhook Delivery

When a webhook tool fires, Ojin sends an HTTP request to the tool's configured URL:

* Method `POST`, header `Content-Type: application/json`.
* A 30-second timeout.

The request body carries the call under a `tool_call_message` object. The fields you'll use:

```json
{
  "tool_call_message": {
    "name": "get_order_status",
    "parameters": "{\"order_id\":\"A123\"}",
    "tool_call_id": "call_abc",
    "response_required": true,
    "tool_type": "function"
  }
}
```

| Field               | Meaning                                                        |
| ------------------- | -------------------------------------------------------------- |
| `name`              | The tool that was called                                       |
| `parameters`        | The call arguments, as a **JSON string** (parse it before use) |
| `tool_call_id`      | Unique id for this call                                        |
| `response_required` | `true` when the agent is waiting for your response (see below) |

The body also includes additional session metadata fields alongside `tool_call_message`; you can ignore them for most integrations.

{% hint style="warning" %}
The webhook request is **not signed**. There is no secret header. Treat the webhook URL itself as a secret, serve it over HTTPS, and validate the payload before acting on it.
{% endhint %}

### Waiting for a response

Each webhook tool has a **wait for response** setting:

* **On** (`response_required: true`), the agent waits for your endpoint, then feeds the result back into the conversation so the LLM can use it. Return HTTP `2xx` with a JSON body:

  ```json
  { "result": "Your order shipped on Tuesday." }
  ```

  If you omit the `result` key, the entire JSON body is used as the result. An empty body (or `204`) is treated as an empty result. A non-`2xx` status, a timeout, or an unparseable body surfaces an error result to the LLM instead.
* **Off** (`response_required: false`), fire-and-forget. Ojin sends the request and does not wait; the LLM immediately continues with a neutral acknowledgement. Anything your endpoint returns is ignored.

## Client Delivery

When a client tool fires, the widget dispatches a DOM `CustomEvent` named `ojinToolCall`. The event fires on **`window`** (not on the `<ojin-agent>` element), so add the listener to `window`:

```js
window.addEventListener("ojinToolCall", (event) => {
  const { function_name, tool_call_id, arguments: args } = event.detail;
  // Run the browser-side action for `function_name` using `args`.
  // `args` is already an object, no parsing needed.
});
```

The `arguments` field is renamed to `args` above because `arguments` is a reserved word inside regular functions.

`event.detail` carries:

| Field           | Type     | Meaning                            |
| --------------- | -------- | ---------------------------------- |
| `function_name` | `string` | The tool that was called           |
| `tool_call_id`  | `string` | Unique id for this call            |
| `arguments`     | `object` | The call arguments, already parsed |

{% hint style="info" %}
Client delivery is **one-way**. There is no path to send a value back to the agent from the browser; the agent continues as soon as it dispatches the call. If the LLM needs to use a result, use **webhook** delivery with *wait for response* turned on.
{% endhint %}

## Managing Tools via the REST API

Agent endpoints accept your API key, so you can manage tools programmatically. Authenticate with the `X-API-Key` header (see [Get your API key](/getting-started/authentication)).

Tools live on the agent itself. Set them with the agent update endpoint, `PATCH /v1/agents/{agent_id}`, using the `tools` array:

```bash
curl -X PATCH https://api.ojin.ai/v1/agents/$AGENT_ID \
  -H "X-API-Key: $OJIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tools": [
      {
        "enabled": true,
        "function": {
          "name": "open_pricing",
          "description": "Open the pricing page for the user.",
          "parameters": {
            "type": "object",
            "properties": { "plan": { "type": "string" } }
          }
        },
        "delivery": { "mode": "client" }
      },
      {
        "enabled": true,
        "function": {
          "name": "create_ticket",
          "description": "File a support ticket.",
          "parameters": {
            "type": "object",
            "properties": { "summary": { "type": "string" } }
          }
        },
        "delivery": {
          "mode": "webhook",
          "url": "https://example.com/hooks/ticket",
          "wait_for_response": true
        }
      }
    ]
  }'
```

{% hint style="warning" %}
The `tools` array is **full-replace**. Tools are matched by `function.name`: a name already on the agent is updated in place, a new name is created, and **any existing tool whose name is not in the array is deleted**. To change one tool, send the complete set. To leave tools untouched, omit `tools` from the request entirely.
{% endhint %}

To read an agent's current tools (each returned with its server-assigned `tool_id`), use `GET /v1/agents/{agent_id}`. For the full schema, see the [REST API](/getting-started/api) reference.

## Widget Events

Beyond `ojinToolCall`, the `<ojin-agent>` widget emits DOM events you can listen for to build custom UI, transcripts, or telemetry around the conversation. All events fire on **`window`**.

### Conversation events

| Event                          | `detail`                                      | Fires when                                                                              |
| ------------------------------ | --------------------------------------------- | --------------------------------------------------------------------------------------- |
| `ojinToolCall`                 | `{ function_name, tool_call_id, arguments }`  | A client-delivered tool is called (see above)                                           |
| `botIsSpeaking`                | `{ isSpeaking: boolean }`                     | The agent starts or stops speaking                                                      |
| `ReceivedTranscriptUser`       | `{ text, timestamp, user, final }`            | A user speech transcript arrives (`final: false` for interim)                           |
| `ReceivedTranscriptBot`        | `{ text }`                                    | A chunk of the agent's response text arrives                                            |
| `ReceivedTranscriptBotPartial` | `{ owner: "bot", text, final: false }`        | The cumulative text produced by the speech service for the agent's current turn updates |
| `BotTtsStopped`                | *no payload (`detail` is `null`)*             | The agent's current spoken turn ends or is interrupted                                  |
| `UserStartedSpeaking`          | *no payload (`detail` is `null`)*             | The user starts speaking                                                                |
| `UserStoppedSpeaking`          | *no payload (`detail` is `null`)*             | The user stops speaking                                                                 |
| `ConnectionTimeout`            | `{ duration_s, bot_state, connection_state }` | Connection setup exceeds its timeout; the widget resets and shows an error              |

```js
window.addEventListener("ReceivedTranscriptUser", (event) => {
  const { text, final } = event.detail;
  if (final) console.log("User said:", text);
});
```

### Advanced / telemetry events

These carry fine-grained audio and timing signals, useful for meters, latency dashboards, or debugging. They are more granular than the conversation events above and may change. Don't depend on them for core logic.

| Event                    | `detail`                                                                                                | Fires when                                             |
| ------------------------ | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `userSpeakingChanged`    | `{ isSpeaking: boolean }`                                                                               | Browser voice-activity detection toggles               |
| `userVolume`             | `{ volume: number }`                                                                                    | The user's microphone level is sampled                 |
| `botVolume`              | `{ volume: number }`                                                                                    | The agent's playback level is sampled                  |
| `LatencyReport`          | per-utterance latency breakdown                                                                         | The agent reports timing for a turn                    |
| `ConnectionTimingReport` | connection timing breakdown                                                                             | The session finishes connecting                        |
| `OjinSessionOutcome`     | `{ outcome, phase, faultDomain, sessionId, agentId, instanceId, httpStatus, elapsedMs, widgetVersion }` | A connection attempt or a live session ends. See below |

#### `OjinSessionOutcome`

Fires once when a connection attempt finishes and again when a live session ends, so you can measure how often visitors reach a conversation and why they don't. `phase` is `connect` or `session`; `outcome` is a value such as `success`, `mic_permission_denied`, `agent_unavailable`, `transport_lost` or `ended_by_visitor`. `faultDomain` coarsely attributes it, `client_env`, `ojin`, `backend`, or `unknown` when the outcome carries no attribution (every normal ending does).

`instanceId` identifies which `<ojin-agent>` element it came from, since the event fires on `window` and a page may host several. `sessionId` is present only once a session exists, a microphone refused before the session starts has none.

Treat the `outcome` vocabulary as open: new values are added as new failure modes are identified, so match on the ones you care about rather than assuming the list is closed.

## Next Steps

* [**Create & Configure**](/apps/overview/configure): set up the agent these tools belong to
* [**Widget Integration**](/apps/overview/widget-integration): embed the agent on your page
* [**REST API**](/getting-started/api): full schema for programmatic management


# Widget Integration

Embed a fully interactive Human Agent on your website with a single HTML tag.

## Quick Start

Load the widget bundle once, then drop the `<ojin-agent>` tag wherever you want it. No backend code is required: the widget calls Ojin directly.

{% tabs %}
{% tab title="Plain HTML" %}

```html
<!DOCTYPE html>
<html>
  <body>
    <script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
    <ojin-agent
      agent-id="your-agent-id"
      core-api-endpoint="https://api.ojin.ai"
      style="position: fixed; bottom: 24px; right: 24px; width: 320px; height: auto; z-index: 9999;"
    ></ojin-agent>
  </body>
</html>
```

{% endtab %}

{% tab title="React / Next.js" %}
The widget is a web component, so it needs to mount on the client. It reads `window` at mount time, which is why the App Router example loads the script with `strategy="afterInteractive"` rather than during server rendering.

Declaring the element in `IntrinsicElements` is what stops TypeScript rejecting the tag.

```tsx
// app/layout.tsx (Next.js App Router)
import Script from "next/script";

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "ojin-agent": React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement>,
        HTMLElement
      > & {
        "agent-id"?: string;
        "core-api-endpoint"?: string;
        collapsable?: string;
        "default-collapsed"?: string;
        "enable-video"?: string;
        "preview-url"?: string;
        "widget-resizable"?: string;
      };
    }
  }
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script
          src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"
          strategy="afterInteractive"
        />
        <ojin-agent
          agent-id="your-agent-id"
          core-api-endpoint="https://api.ojin.ai"
          style={{ position: "fixed", bottom: 24, right: 24, width: 320, height: "auto", zIndex: 9999 }}
        />
      </body>
    </html>
  );
}
```

**Vite** and **Create React App** have no `next/script`, so put the bundle in the HTML shell instead, then use the tag in any component. Vite uses the project-root `index.html`, Create React App uses `public/index.html`.

```html
<script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
```

{% endtab %}

{% tab title="Vue" %}
Load the bundle once in `index.html`, then use the tag in any template.

```html
<!-- index.html: load the widget bundle once -->
<script src="https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js"></script>
```

Vue 3's template compiler treats an unknown tag as a component, so tell it `ojin-agent` is a custom element. Without this you get a `Failed to resolve component: ojin-agent` warning:

```js
// vite.config.js
import vue from "@vitejs/plugin-vue";

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.startsWith("ojin-"),
        },
      },
    }),
  ],
};
```

```vue
<!-- Any Vue component: drop the custom element into a template -->
<template>
  <ojin-agent
    agent-id="your-agent-id"
    core-api-endpoint="https://api.ojin.ai"
    style="position: fixed; bottom: 24px; right: 24px; width: 320px; height: auto; z-index: 9999;"
  />
</template>
```

{% endtab %}

{% tab title="REST + Daily" %}
Only reach for this if you are building your own UI. You give up everything the widget handles: the avatar layout, audio controls, captions, reconnection and session teardown.

Call [`POST /v1/public/agents/connect`](/apps/overview/api-reference) to get a Daily room and a token, then join the room yourself.

```ts
// Install: npm i @daily-co/daily-js
import DailyIframe from "@daily-co/daily-js";

const AGENT_ID = "your-agent-id";
const CONNECT_URL = "https://api.ojin.ai/v1/public/agents/connect";

async function startSession(clientUserRef?: string) {
  const res = await fetch(CONNECT_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ agent_id: AGENT_ID, client_user_ref: clientUserRef }),
  });
  if (!res.ok) throw new Error(`connect failed: ${res.status}`);

  const { room_url, token } = await res.json();
  const call = DailyIframe.createCallObject();
  await call.join({ url: room_url, token });
  return call;
}
```

The response also carries `session_id` and `agent_id`. End the session with the cancel beacon described in the [Session API Reference](/apps/overview/api-reference).
{% endtab %}
{% endtabs %}

{% hint style="info" %}
The same script URL, `core-api-endpoint` and your agent id are in the embed snippet on your agent's settings page in the [dashboard](https://ojin.ai/dashboard). Copy that snippet rather than retyping the id.
{% endhint %}

## Embed Attributes

| Attribute           | Default              | Description                                                                                                                                                     |
| ------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent-id`          | required             | The agent to connect to. Copy it from the agent settings page                                                                                                   |
| `core-api-endpoint` | required             | `https://api.ojin.ai`                                                                                                                                           |
| `enable-video`      | `true`               | Set `"false"` for an audio-only agent                                                                                                                           |
| `collapsable`       | `true` when floating | Set `"false"` to pin the widget open. `widget-placement="inline"` is an alias for this                                                                          |
| `default-collapsed` | `false`              | Set `"true"` to load collapsed. Only the value at mount matters; use `collapse()` and `expand()` afterwards                                                     |
| `widget-placement`  | `floating`           | `inline` embeds the widget in the page flow instead of floating over it                                                                                         |
| `widget-resizable`  | `false`              | Set `"true"` so visitors can scale the floating launcher from the corners. Ignored when inline. After a visitor resize, the Big/Small preset no longer applies. |

`preview-url` appears in the TypeScript declaration above because the widget *writes* it, exposing the resolved preview image. Do not set it yourself.

## Appearance & Layout

The widget renders as a floating overlay on your page. It includes the avatar video feed, audio controls, and session management: all self-contained.

The agent owner can choose `off`, `conversation`, `tiktok`, `youtube`, `arthouse`, or `journalist` in the dashboard. They can also choose dark or light text, an accent colour, text size, and no background or a solid background. New agents start with captions off.

Caption settings come from the agent configuration. The embed has no caption attributes or viewer controls. Captions hide while the viewer opens the chat panel and return when it closes.

{% hint style="info" %}
Widget styling and positioning options are configured in your agent's settings in the [dashboard](https://ojin.ai/dashboard). Check your agent's widget configuration for available customization options.
{% endhint %}

## Finding Your Agent ID

After [creating and configuring your agent](/apps/overview/configure), copy the agent ID from the agent settings page in the [Ojin dashboard](https://ojin.ai/dashboard).

## Controlling Access

Agents are **private by default**: connections from any website are rejected until you configure access.

### Hostname Allowlist

In the [dashboard](https://ojin.ai/dashboard), add the domains that are allowed to connect to your agent:

| Configuration                    | Behaviour                                           |
| -------------------------------- | --------------------------------------------------- |
| No hostnames configured          | **Private**: all connections rejected               |
| `myapp.com`, `staging.myapp.com` | Only requests from those exact domains are accepted |
| `*`                              | **Public**: connections accepted from any domain    |

{% hint style="warning" %}
Using `*` (wildcard) allows anyone to connect to your agent. This is useful for demos but not recommended for production. Use specific hostnames to restrict access to your own domains.
{% endhint %}

{% hint style="info" %}
No API key is needed in the widget. Access is controlled entirely by the hostname allowlist: the server checks the request's origin against your configured domains.
{% endhint %}

## How It Works

When the widget loads on your page:

1. The widget calls the Session API (`POST /v1/public/agents/connect`) with your agent ID
2. The server validates the request origin against the hostname allowlist
3. On success, the server returns a WebRTC room URL and token
4. The widget connects to the WebRTC room directly
5. Audio, video, and signaling all flow over a single WebRTC connection

The entire handshake takes a few seconds. After that, your user is in a live conversation with the agent.

## Network Requirements

If your site sets a Content Security Policy, the widget needs these sources allowed. Blocking the widget script stops it loading at all; blocking anything else fails quietly, leaving the widget running with the affected capability gone. The "If blocked" column says which is which.

| Source                          | Directive              | Purpose                                                                                            | If blocked                                                                  |
| ------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `https://cdn.jsdelivr.net`      | `script-src`           | The widget bundle itself                                                                           | The widget does not load                                                    |
| `https://api.ojin.ai`           | `connect-src`          | Session API: starting and monitoring a session                                                     | Sessions cannot start                                                       |
| `https://*.daily.co`            | `connect-src`          | WebRTC signalling, and fetching the call engine the widget joins the room with                     | No audio or video                                                           |
| `https://*.pluot.blue`, `wss:`  | `connect-src`          | Daily's media servers and their signalling sockets                                                 | No audio or video                                                           |
| `'unsafe-eval'`, `blob:`        | `script-src`           | The call engine is fetched at runtime and evaluated in the page                                    | No audio or video                                                           |
| `'self'`, `blob:`               | `worker-src`           | Daily runs noise cancellation in a worker started from a blob URL                                  | No audio or video                                                           |
| `https://*.amazonaws.com`       | `media-src`, `img-src` | The agent's idle preview video and poster image: the Session API returns them as presigned S3 URLs | The idle preview is blank; sessions still work                              |
| `https://fonts.googleapis.com`  | `style-src`            | The Manrope webfont stylesheet                                                                     | The widget renders in the fallback system font stack; everything else works |
| `https://fonts.gstatic.com`     | `font-src`             | The Manrope font files                                                                             | Same as above                                                               |
| `'unsafe-inline'`               | `style-src`            | The widget's components inject their own styles at runtime                                         | The widget renders unstyled                                                 |
| `https://*.ingest.us.sentry.io` | `connect-src`          | Error and reliability reporting                                                                    | Reporting stops; conversations are unaffected                               |

A policy that covers all of it:

```
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net 'unsafe-eval' blob:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob: https://*.amazonaws.com;
media-src 'self' blob: https://*.amazonaws.com;
connect-src 'self' https://api.ojin.ai https://*.daily.co https://*.pluot.blue https://*.ingest.us.sentry.io wss:;
worker-src 'self' blob:;
frame-src 'self';
```

{% hint style="info" %}
`'unsafe-eval'` is needed because the WebRTC SDK downloads its call engine at connect time and evaluates it in the page. If your policy cannot allow it, get in touch: the SDK has a script-loading mode that trades `'unsafe-eval'` for `script-src https://*.daily.co`.
{% endhint %}

The widget also needs `microphone` permission via `Permissions-Policy` if you serve one, and WebRTC must not be disabled by a browser extension or corporate proxy.

Note the widget has always contacted a Sentry host: the WebRTC library it embeds reports its own errors there. Ojin's own reporting uses the same host.

### What the widget reports

The widget records how each connection attempt ended (succeeded, the microphone was refused, the agent was unavailable, the connection dropped) so we can find and fix problems that only appear on real devices.

It does **not** send conversation audio, video, transcripts, or anything your user types. The conversation itself is never recorded. It does not set cookies for this, and does not send an identifier that follows a visitor between sessions. What it sends is the outcome, a coarse attribution of where the fault lay, a coarse browser family (for example "Safari"), whether the page is inside an in-app browser, the microphone permission state, the HTTP status when the connect was rejected, the widget version, the avatar model variant that served the attempt, and, when an attempt timed out without reporting, how far it had got.

## Reacting to the Agent

The widget emits DOM events on `window` while a session runs (speaking state, live transcripts, and client-delivered tool calls) so your page can build custom UI or trigger actions. See [**Tools & Events**](/apps/overview/tools) for the full event reference and for handling function-calling tools.

## Custom Integrations

If you need more control than the widget provides, for example building a native mobile app or a custom web experience, you can call the Session API directly from your backend and connect to the WebRTC room yourself. The **REST + Daily** tab in [Quick Start](#quick-start) shows the browser version of this.

See the [Session API Reference](/apps/overview/api-reference) for details.

## Troubleshooting

### Widget not loading

* Use `https://cdn.jsdelivr.net/npm/@ojin/agent@latest/dist/ojin-agent.js`. Do not use `https://widget.ojin.ai/ojin-agent.js`: that host has no DNS record, so the script never loads and `<ojin-agent>` stays an empty tag.
* Confirm the `agent-id` attribute matches your agent's ID in the dashboard
* Set `core-api-endpoint` to the value in the dashboard snippet (`https://api.ojin.ai` in production)

### "agent\_offline" error

* Check that your agent's status is set to **online** in the [dashboard](https://ojin.ai/dashboard)
* Ensure all required fields are filled: the dashboard won't let you go online with missing configuration

### "auth\_failed" error

* Verify your domain is in the agent's hostname allowlist in the [dashboard](https://ojin.ai/dashboard)
* Check that the `Origin` header sent by the browser matches one of your configured hostnames exactly
* If testing locally, add `localhost` or `127.0.0.1` to the allowlist

### "concurrency\_limit" error

* Your agent's maximum concurrent sessions are in use. Wait for an active session to end or increase the **max concurrency** setting in the dashboard

### No audio or video

* Ensure the user's browser has granted microphone and camera permissions
* Check that the WebRTC connection is not blocked by a firewall or corporate proxy


# Session API Reference

Start agent sessions programmatically. The widget uses this API automatically, call it directly for custom integrations.

## Base URL

```
https://api.ojin.ai
```

There is one endpoint. It is not regional, so there is no region to choose. Every path below already includes the `/v1` prefix, so append it to the base URL as written.

{% hint style="info" %}
**This is not the same endpoint as the one the "deploy in US East" guidance refers to.** They are two different things:

* **`api.ojin.ai` (this API)** is the agent control plane. You call it to start and cancel sessions. One global endpoint, no region in the hostname.
* **The models WebSocket** is where audio and video stream. If you drive the face models yourself with the [Python SDK](/models/build-with-python-sdk) or the [realtime API](/models/introduction/api), run **your own backend** in US East, close to Ojin's inference. See [Optimizing Performance](/guides/optimizing-performance).

If you only use the Human Agent widget or this Session API, the US East guidance does not apply to you.
{% endhint %}

## Start a Session

### `POST /v1/public/agents/connect`

Start a new session for a given agent. Returns connection details for joining the WebRTC room.

### Request

**Headers:**

| Header         | Required | Description        |
| -------------- | -------- | ------------------ |
| `Content-Type` | Yes      | `application/json` |

**Body:**

```json
{
  "agent_id": "your-agent-id",
  "client_user_ref": "optional-user-identifier"
}
```

| Field             | Type   | Required | Description                                                                                                     |
| ----------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- |
| `agent_id`        | string | Yes      | The ID of the agent to start a session with                                                                     |
| `client_user_ref` | string | No       | An opaque identifier for your end user. Appears in call history for analytics and tracking. Max 256 characters. |

### Response (200 OK)

```json
{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "room_url": "https://example.daily.co/room-name",
  "token": "eyJhbGciOiJIUzI1NiIs..."
}
```

| Field        | Type   | Description                               |
| ------------ | ------ | ----------------------------------------- |
| `session_id` | string | Unique identifier for this session        |
| `room_url`   | string | WebRTC room URL to connect to             |
| `token`      | string | Authentication token for joining the room |

Use `room_url` and `token` to connect to the WebRTC room from your client. If you're building a web application, you can use the [Daily JavaScript SDK](https://docs.daily.co/reference/daily-js) to join the room.

### Error Responses

All errors return a JSON body with `error_code` and `message`:

```json
{
  "error_code": "agent_unpublished",
  "message": "Agent exists but is currently unpublished"
}
```

For `concurrency_limit` errors, the response includes an additional `retry_after_seconds` field indicating how long to wait before retrying:

```json
{
  "error_code": "concurrency_limit",
  "message": "Max concurrency reached",
  "retry_after_seconds": 5
}
```

| Error Code             | HTTP Status | Description                                                      |
| ---------------------- | ----------- | ---------------------------------------------------------------- |
| `agent_not_found`      | 404         | Agent ID does not exist or is not visible to the caller          |
| `agent_unpublished`    | 403         | Agent exists but status is unpublished                           |
| `auth_failed`          | 403         | Hostname allowlist validation failed                             |
| `concurrency_limit`    | 429         | Max concurrency reached. Response includes `retry_after_seconds` |
| `room_creation_failed` | 502         | WebRTC room could not be created                                 |
| `orchestrator_error`   | 502         | Agent orchestrator is unreachable or returned an error           |
| `internal_error`       | 500         | Unexpected server error                                          |

### Authentication

Currently, the public Session API uses **hostname allowlist** authentication. When a browser request arrives, the server checks the `Origin` header against the domains configured on the agent:

* If the origin matches an allowed hostname, the request proceeds
* If no hostnames are configured, all requests are rejected (agent is private)
* If `*` is configured, all origins are accepted

No API key or token is needed from the client side when using hostname allowlist.

{% hint style="info" %}
Server-side calls without a browser `Origin` header only work for agents configured with `*` in their allowed hostnames. Production server-to-server authentication is planned separately.
{% endhint %}

## Cancel a Session by Room

### `POST /v1/public/agents/connect/{room}`

Cancel an active public session after a room has been allocated. This is mainly used by browser clients during unload, tab-hide, or abort cleanup.

`{room}` is the Daily room name from the `room_url` returned by `POST /v1/public/agents/connect`.

### Cancel Response (200 OK)

```json
{
  "released": 1
}
```

| Field      | Type   | Description                                                                         |
| ---------- | ------ | ----------------------------------------------------------------------------------- |
| `released` | number | Number of active sessions released. `0` means the room was already gone or unknown. |

## Examples

{% tabs %}
{% tab title="curl" %}

```bash
# Works only for wildcard/demo agents because curl does not attach a browser Origin.
curl -X POST https://api.ojin.ai/v1/public/agents/connect \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "your-agent-id"}'
```

{% endtab %}

{% tab title="Python" %}

```python
import requests

# Works only for wildcard/demo agents because server-side HTTP clients do not
# attach a browser Origin.
response = requests.post(
    "https://api.ojin.ai/v1/public/agents/connect",
    json={"agent_id": "your-agent-id"}
)

data = response.json()
print(f"Session: {data['session_id']}")
print(f"Room: {data['room_url']}")
```

{% endtab %}

{% tab title="JavaScript" %}

```javascript
const response = await fetch(
    "https://api.ojin.ai/v1/public/agents/connect",
    {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ agent_id: "your-agent-id" })
    }
);

const { session_id, room_url, token } = await response.json();
```

Release the session when the user leaves the page:

```javascript
const room = new URL(room_url).pathname.split("/").filter(Boolean).pop();

if (room) {
  navigator.sendBeacon(
    `https://api.ojin.ai/v1/public/agents/connect/${encodeURIComponent(room)}`,
    ""
  );
}
```

{% endtab %}
{% endtabs %}

## Connecting to the Room

After receiving the `room_url` and `token` from the Session API, connect to the WebRTC room using the [Daily JavaScript SDK](https://docs.daily.co/reference/daily-js):

```bash
npm install @daily-co/daily-js
```

```javascript
import DailyIframe from '@daily-co/daily-js';

// Create a call frame (attaches to DOM automatically)
const callFrame = DailyIframe.createFrame();

// Join the room with the token from the Session API
await callFrame.join({ url: room_url, token: token });

// The agent's audio and video tracks are now available
callFrame.on('track-started', (event) => {
    if (event.participant && !event.participant.local) {
        // Remote participant = the agent
        // event.track contains the audio or video MediaStreamTrack
    }
});
```

{% hint style="info" %}
This is only needed for custom integrations. If you're using the [widget](/apps/overview/widget-integration), it handles the room connection automatically.
{% endhint %}


# Introduction

Ojin offers two real-time face models. [**Human Portrait**](/models/human-portrait) and [**Human Presence**](/models/human-presence). Both turn speech audio into a synchronized talking-avatar video in real time, and both are driven the same way: pick the model with its `config_id` and the integration code is identical.

## Presence vs Portrait, which face model?

{% columns %}
{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-f4d10a50059e93f330c4bda4bfa4b79749e21928%2Fcompare-presence.avif?alt=media" alt="" height="240" width="145"><figcaption><p><strong>Human Presence</strong>, expression and movement, including the hands.</p></figcaption></figure>
{% endcolumn %}

{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-68672379c36e2a037ffcfffb6b68ad9c73d09a7a%2Fcompare-portrait.avif?alt=media" alt="" height="240" width="240"><figcaption><p><strong>Human Portrait</strong>, a focused talking face at the best price.</p></figcaption></figure>
{% endcolumn %}
{% endcolumns %}

*Two personas, one rendered by each model. Presence moves through the whole frame; Portrait concentrates on the face.*

|          | **Human Presence**                                    | **Human Portrait**                     |
| -------- | ----------------------------------------------------- | -------------------------------------- |
| Quality  | Flagship, the most expressive and lifelike            | Great, focused on the talking face     |
| Motion   | Generative expressions **and** movement (incl. hands) | Lip-sync with facial expression        |
| Best for | When quality and expressiveness matter most           | A solid talking face at the best price |
| Cost     | Higher                                                | Lower                                  |

Both run in real time, and both are driven by the same [Python SDK](/models/build-with-python-sdk) and [Pipecat](/models/introduction/integrations) integration. You select the model with its `config_id`. To build, head to [**Get started**](/models/introduction/integrations).


# Get started

Build a real-time **Ojin** avatar in minutes, the same flow drives both [Human Portrait](/models/human-portrait) and [Human Presence](/models/human-presence). Pick the integration that fits your stack. We recommend the **Python SDK** or **Pipecat**. The raw WebSocket API is there when you need low-level control.

## Prerequisites

1. An Ojin account with an active API key, if you don't have one, [get your API key](/getting-started/authentication).
2. [Create a custom persona](/models/introduction/creating-persona) or use a [Persona Template](/models/introduction/using-persona-template).
3. A `config_id` (your Persona Configuration ID) from the [dashboard](https://ojin.ai/dashboard).

{% hint style="info" %}
**Production deployments:** the realtime transport is a WebSocket for **server-to-server** use over a stable connection. Drive it from a backend server (ideally in **US East**, near Ojin's inference) to keep your API key secure and latency low, then deliver the final media to end users over a transport built for real-time video (typically WebRTC). The Python SDK and Pipecat integration are designed to run server-side.
{% endhint %}

{% tabs %}
{% tab title="Python SDK" %}

### Python SDK

The [`ojin-client`](https://pypi.org/project/ojin-client/) SDK is the fastest way to drive Ojin from your own code. It streams a synchronized talking-avatar video from the TTS audio you feed it.

```bash
pip install "ojin-client[stv]"
```

```python
import os
from ojin.stv import OjinSTVClient

client = OjinSTVClient(
    api_key=os.environ["OJIN_API_KEY"],
    config_id=os.environ["OJIN_CONFIG_ID"],  # your Human Portrait or Human Presence config
)
# Feed TTS audio with client.say(...), or start_turn() + send_tts_audio() to stream,
# and consume the synced 25 fps audio + video from client.output_stream().
```

**Full walkthrough →** [**Build with the Python SDK**](/models/build-with-python-sdk)**.**
{% endtab %}

{% tab title="Pipecat" %}

### Pipecat

Drop an Ojin face into a [Pipecat](https://github.com/pipecat-ai/pipecat) voice agent with the [`pipecat-ojin`](https://pypi.org/project/pipecat-ojin/) package. `OjinVideoService` is the one stage that turns a voice agent into a video-call avatar. It sits right after your TTS and lip-syncs to it:

```mermaid
flowchart LR
    A["transport.input()"] --> B[STT] --> C[LLM] --> D[TTS]
    D --> E["OjinVideoService"]
    E --> F["transport.output()"]

    style E fill:#6366f1,stroke:#4338ca,color:#ffffff
```

`OjinVideoService` is the only stage you add. Everything upstream of it is your existing voice pipeline, unchanged.

```bash
pip install pipecat-ojin
```

```python
from pipecat_ojin import OjinVideoService, OjinVideoSettings

avatar = OjinVideoService(
    OjinVideoSettings(
        api_key="OJIN_API_KEY",
        config_id="OJIN_CONFIG_ID",   # your Human Portrait or Human Presence config
        image_size=(512, 512),         # must match the transport's video_out size
    )
)
```

#### How it works

1. The microphone captures speech input
2. Voice Activity Detection identifies speech segments
3. Your **STT** service transcribes user audio to text
4. Your **LLM** generates the assistant's reply
5. Your **TTS** service synthesizes the reply to audio
6. `OjinVideoService` animates your persona from that TTS audio
7. Synchronized video and audio frames stream back to the client in real time

A complete, runnable voice + avatar agent (browser WebRTC or Daily) lives in [`pipecat-ojin/examples/ojin-bot`](https://github.com/ojinai/pipecat-ojin/tree/main/examples/ojin-bot). You bring the STT / LLM / TTS services for the pipeline.
{% endtab %}

{% tab title="WebSocket (advanced)" %}

### WebSocket (advanced)

For low-level control, connect directly to the real-time WebSocket API and implement the binary protocol yourself. See the [API Reference (advanced) →](/models/introduction/api).
{% endtab %}
{% endtabs %}

## Next Steps

* [**Build with the Python SDK →**](/models/build-with-python-sdk): install, auth, events, and the full quickstart
* [**API Reference (advanced) →**](/models/introduction/api): the raw WebSocket protocol for custom integrations


# API reference

{% hint style="info" %}
Most builders should use the [**Python SDK**](/models/build-with-python-sdk) or [**Pipecat**](/models/introduction/integrations). They implement this protocol, buffering, and audio/video sync for you. Read on only if you need low-level WebSocket control.
{% endhint %}

## Overview

Real-time talking head synthesis API. Send speech audio, receive synchronized video and audio frames.

After connecting and receiving `SessionReady`, the server immediately begins streaming video and audio frames at 25fps. It does not wait for you to send anything first. When no speech audio has been sent, the server generates **silence frames** (persona at rest with idle animation). When you send speech audio, the server generates **speech frames** with lip-synced animation synchronized to your audio.

You only need to send speech audio. No silence, padding, or keep-alive messages are required.

{% hint style="info" %}
**Production deployments:** This WebSocket API is intended for **server-to-server** use over a stable connection. Connect from a backend server rather than a front-end client, to keep your API key secure and because the raw WebSocket isn't built for flaky end-user networks. Run the backend in **US East**, close to Ojin's inference, for the lowest latency, and deliver the final media stream to end users over a transport built for varying network conditions, typically **WebRTC**, for smooth, reliable, low-latency playback.
{% endhint %}

***

## How It Works

1. **Connect** to the WebSocket endpoint with your API key and config ID
2. **Receive `SessionReady`**: the server has allocated inference resources for your session
3. **The server starts streaming frames immediately**: silence frames with idle animation, no request needed
4. **Send speech audio** whenever it becomes available (e.g., TTS output from your language model), also buffer it locally for playback
5. **Receive speech frames**: the server transitions to lip-synced animation and returns to silence frames when audio runs out
6. **Render video frames** at 25fps, keeping a small jitter buffer (trim idle frames only if they back up after a stall)
7. **Start playing your buffered TTS audio** when the first speech frame (`frame_type` `1` or `3`) arrives from Ojin, stop when speech ends

### Frame Types

Every frame arrives as a binary `InteractionResponse` containing both a JPEG image and a PCM audio chunk. Frames are always delivered in order. The `frame_type` field classifies each frame:

| `frame_type` | Description                                                                                                                                        |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0`          | **Idle**, persona at rest with idle animation. Generated automatically when no speech audio is queued                                              |
| `1`          | **Speech**, lip-synced animation generated from your audio input                                                                                   |
| `2`          | **Fade-out**, post-cancel ramp back toward idle after an interruption                                                                              |
| `3`          | **Start of speech**, first speech frame of a turn **resuming after an interruption/cancel**. Natural (uninterrupted) turns start with `1`, not `3` |

### Buffering for Network Jitter

The server delivers frames at **realtime 25 fps**, so your buffer does not grow on its own. Keep a **small client-side buffer** (a few frames) to absorb network jitter and prevent stuttering during speech, and start playback once it's filled.

The right buffer size depends on your network conditions and latency requirements: keep it as low as possible to minimize latency, but high enough to absorb jitter without starving playback.

If frames ever back up, for example a brief network stall followed by a burst of queued frames arriving at once. You can recover by trimming **idle** frames (`frame_type == 0`). Only idle frames are safe to drop, never drop speech (`1`), start-of-speech (`3`), or fade-out (`2`) frames.

```python
# When consuming frames from the buffer:
frame = buffer.popleft()

# If frames backed up after a stall, recover by skipping every other idle frame.
# Test the frame you are about to drop, not the one you just took: an idle
# frame is often followed by speech, and dropping that leaves a media gap.
if len(buffer) > target_buffer_size and buffer[0].frame_type == 0:
    skip_counter += 1
    if skip_counter % 2 == 0:
        buffer.popleft()  # drop one idle frame
```

***

## Connection Flow

```mermaid
sequenceDiagram
    participant Client
    participant Server

    Note over Client,Server: Connection
    Client->>Server: WebSocket Connect
    Server->>Client: SessionReady (JSON)

    Note over Client,Server: Server Streams Immediately
    Server->>Client: Frame (idle, frame_type=0)
    Server->>Client: Frame (idle, frame_type=0)
    Server->>Client: Frame (idle, frame_type=0)

    Note over Client,Server: Client Sends Speech Audio
    Client->>Server: InteractionInput (TTS audio chunk 1)
    Client->>Server: InteractionInput (TTS audio chunk 2)

    Note over Client,Server: Server Transitions to Speech
    Server->>Client: Frame (start-of-speech, frame_type=3)
    Server->>Client: Frame (speech, frame_type=1)
    Server->>Client: Frame (speech, frame_type=1)
    Note right of Server: Delivered at realtime 25 fps

    Note over Client,Server: Audio Runs Out → Back to Idle
    Server->>Client: Frame (idle, frame_type=0)
    Server->>Client: Frame (idle, frame_type=0)
    Note right of Client: Client keeps a small jitter buffer
```

***

## WebSocket Handshake

```
GET wss://models.ojin.ai/realtime?config_id=<your-config-id>
Authorization: <your-api-key>
```

Provide your API key in the `Authorization` header and the persona's `config_id` as a query parameter. The server upgrades the connection, sends `SessionReady`, and begins streaming frames immediately.

| Parameter       | In     | Required | Description                                                                          |
| --------------- | ------ | -------- | ------------------------------------------------------------------------------------ |
| `Authorization` | header | yes      | Your raw API key. No `Bearer` prefix                                                 |
| `config_id`     | query  | yes      | Configuration ID for the persona, created in the Human Portrait tab of the dashboard |

| Status | Meaning                                                                                                              |
| ------ | -------------------------------------------------------------------------------------------------------------------- |
| `101`  | Upgrade successful. The server sends a `SessionReady` JSON message, then streams binary `InteractionResponse` frames |
| `401`  | Invalid or missing API key                                                                                           |

Recommended client settings: `ping_interval` 30 seconds, `ping_timeout` 10 seconds.

***

## Message Format

{% hint style="info" %}
**Mixed message types:** Both JSON (text) and binary messages are exchanged on the same WebSocket connection. Your client must check the WebSocket frame type to distinguish them:

* **Text frames (JSON):** `SessionReady`, `ErrorResponse` (server → client), `CancelInteraction` (client → server)
* **Binary frames:** `InteractionResponse` (server → client), `InteractionInput` (client → server)
  {% endhint %}

{% hint style="info" %}
**Byte order:** All multi-byte integer fields in binary messages use **network byte order (big-endian)**.
{% endhint %}

***

## Messages Reference

### Server → Client Messages

| Message                                                            | Frame  | Description                                                                                                                                    |
| ------------------------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `SessionReady`                                                     | JSON   | Sent once after the connection is established and inference resources are allocated. The server begins streaming frames immediately afterwards |
| [`InteractionResponse`](#interactionresponse-server-client-binary) | binary | A video frame and its synchronized audio chunk. Streamed continuously: idle frames when at rest, speech frames when processing your audio      |
| [`ErrorResponse`](#errorresponse-server-client-json)               | JSON   | Sent when an error occurs. In some conditions, such as no backend server being available, the connection may close without one                 |

### Client → Server Messages

| Message                                                      | Frame  | Description                                                                                             |
| ------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------- |
| [`InteractionInput`](#interactioninput-client-server-binary) | binary | Speech audio. Only send speech audio, never silence or padding                                          |
| [`CancelInteraction`](#cancelinteraction)                    | JSON   | Stop processing and discard remaining frames immediately. No final frame is sent. Use for interruptions |

Field-level layouts for the binary messages are in [Message Details](#message-details) below.

***

## Message Details

### InteractionInput (Client → Server, Binary)

Binary message for sending speech audio to the server. **Only send speech audio**. Do not send silence or padding.

**Binary structure:**

```
[1 byte ]  Payload type       : uint8, always 1 for audio
[8 bytes]  Timestamp           : uint64, milliseconds since Unix epoch
[4 bytes]  Params size         : uint32, byte length of the JSON params block (0 if no params)
[N bytes]  Params JSON         : UTF-8 encoded JSON (only present if params size > 0)
[M bytes]  Audio payload       : raw PCM int16 speech audio data
```

**Header fields** use **big-endian** byte order. The PCM audio samples in the payload use **little-endian** (standard for PCM int16). In Python: `struct.pack('!BQI', payload_type, timestamp, params_size)`.

**Audio requirements:**

| Property         | Value                                              |
| ---------------- | -------------------------------------------------- |
| Format           | PCM signed 16-bit integers (little-endian samples) |
| Sample rate      | 16,000 Hz                                          |
| Channels         | 1 (mono)                                           |
| Max message size | 512 KB (entire binary message including header)    |

**Recommended streaming pattern:**

The server runs a 25 fps virtual timeline and needs your audio **input to stay slightly ahead** of it. Forwarding tiny TTS fragments one at a time (e.g. 40 ms chunks) makes input rate match output rate, so the server starves and emits idle frames between speech, lip-sync skips or drifts. Instead, feed audio with a cushion, **not** per-fragment:

1. **Prime \~1 second** of speech audio at the start of a turn.
2. Then send the **largest chunks you can**, coalesce queued fragments into **\~400 ms** sends (under the 512 KB cap).
3. Stay realtime: never wait for the whole utterance before sending.

{% hint style="success" %}
The [**Python SDK**](/models/build-with-python-sdk) and [**Pipecat**](/models/introduction/integrations) shape the input for you. `OjinSTVClient` primes the lead and coalesces your TTS into large chunks automatically, so you just feed audio as it arrives. This pattern only applies if you drive this WebSocket API directly. See [Optimizing Performance](/guides/optimizing-performance).
{% endhint %}

```python
import struct, json, time

def build_audio_message(audio_bytes):
    header = struct.pack('!BQI',
        1,                         # payload type: audio
        int(time.time() * 1000),   # timestamp ms
        0,                         # params size (unused for human-portrait)
    )
    return header + audio_bytes
```

***

### InteractionResponse (Server → Client, Binary)

Binary message containing a video frame and synchronized audio. The server streams these continuously after `SessionReady`. **Frames always arrive in order.**

**Binary structure:**

```
[1 byte  ]  Is final flag      : uint8, 1 = last frame for this interaction, 0 = more coming
[16 bytes]  Interaction ID      : UUID bytes (big-endian)
[8 bytes ]  Timestamp           : uint64, milliseconds since Unix epoch
[4 bytes ]  Usage               : uint32, usage metric for this response
[4 bytes ]  Reserved            : uint32, reserved; ignore
[4 bytes ]  Num payloads        : uint32, number of payload entries that follow

For each payload entry:
  [4 bytes]  Data size           : uint32, byte length of the payload data only
  [1 byte ]  Payload type        : uint8, 1 = audio, 2 = image
  [N bytes]  Payload data        : raw payload bytes

[1 byte ]  Frame type          : uint8, appended after all payload entries: 0=idle, 1=speech, 2=fade-out, 3=start-of-speech
```

All multi-byte integers are **big-endian**. In Python: `struct.unpack('!B16sQIII', header_bytes)` for the main header, `struct.unpack('!IB', entry_bytes)` for each payload entry. The `Frame type` byte is a single trailing `uint8` after the last payload entry and is the authoritative frame classifier.

**Frame type:**

| Frame type | Meaning                                                                                                                |
| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| `0`        | **Idle**, persona at rest with idle animation                                                                          |
| `1`        | **Speech**, lip-synced animation from your audio                                                                       |
| `2`        | **Fade-out**, post-cancel ramp toward idle                                                                             |
| `3`        | **Start of speech**, first speech frame of a turn resuming after an interruption/cancel (natural turns start with `1`) |

**Payload types:**

| Type      | Format                | Typical size per frame                                 |
| --------- | --------------------- | ------------------------------------------------------ |
| 1 (audio) | PCM int16, 16kHz mono | **1,280 bytes** (640 samples = 40ms at 25fps)          |
| 2 (image) | JPEG-encoded image    | Variable (resolution depends on config, e.g. 1280×720) |

**Parsing example:**

```python
import struct, uuid

HEADER_FMT = '!B16sQIII'
HEADER_SIZE = struct.calcsize(HEADER_FMT)   # 37 bytes
ENTRY_FMT = '!IB'
ENTRY_SIZE = struct.calcsize(ENTRY_FMT)     # 5 bytes

def parse_response(data):
    # The 5th header field is reserved; ignore it.
    is_final, uuid_bytes, timestamp, usage, _reserved, num_payloads = \
        struct.unpack(HEADER_FMT, data[:HEADER_SIZE])

    offset = HEADER_SIZE
    image = audio = None

    for _ in range(num_payloads):
        size, ptype = struct.unpack(ENTRY_FMT, data[offset:offset + ENTRY_SIZE])
        offset += ENTRY_SIZE
        payload = data[offset:offset + size]
        offset += size

        if ptype == 2:
            image = payload   # JPEG bytes
        elif ptype == 1:
            audio = payload   # PCM int16 bytes

    # Trailing frame type byte, appended after the payload entries.
    frame_type = data[offset]

    return {
        'is_final': bool(is_final),
        'frame_type': frame_type,    # 0=idle, 1=speech, 2=fade-out, 3=start-of-speech
        'image': image,
        'audio': audio,
    }
```

***

### CancelInteraction

| Message             | Purpose        | Server behavior                             | Use case          |
| ------------------- | -------------- | ------------------------------------------- | ----------------- |
| `CancelInteraction` | Immediate stop | Stops processing, discards remaining frames | User interruption |

***

### ErrorResponse (Server → Client, JSON)

{% hint style="warning" %}
**Plain text errors:** In some error conditions (e.g., no backend servers available), the server may send a plain text message instead of a structured JSON `ErrorResponse`. Your client should handle non-JSON text messages gracefully.
{% endhint %}

**Error codes:**

| Code                  | Description                              |
| --------------------- | ---------------------------------------- |
| `AUTH_FAILED`         | Invalid API key                          |
| `UNAUTHORIZED`        | Caller lacks permission                  |
| `MISSING_CONFIG_ID`   | `config_id` query parameter not provided |
| `INVALID_MESSAGE`     | Malformed or unsupported message payload |
| `INVALID_HEADERS`     | Missing or invalid headers               |
| `MODEL_NOT_FOUND`     | Config ID not found or invalid           |
| `BACKEND_UNAVAILABLE` | No healthy inference backend available   |
| `RATE_LIMITED`        | Too many requests                        |
| `TIMEOUT`             | Operation exceeded processing time       |
| `CANCELLED`           | Interaction cancelled by client          |
| `INTERNAL_ERROR`      | Unexpected server error                  |
| `FRAME_SIZE_EXCEEDED` | Message exceeded 512KB limit             |

***

## Rate Limits & Constraints

| Constraint       | Value                      |
| ---------------- | -------------------------- |
| Rate limit       | 6 requests per second      |
| Max message size | 512 KB per message         |
| Video output     | 25 fps (realtime delivery) |

Exceeding limits results in an `ErrorResponse` with code `RATE_LIMITED`.

***

## Best Practices

### Audio Input

* **Send speech audio only.** The server starts streaming idle frames on its own, so there is no handshake frame to send first and no need to pad the gaps between utterances
* **Prime \~1 s of audio, then send the largest chunks you can (\~400 ms).** The server needs your input to stay slightly ahead of its 25 fps timeline. Forwarding tiny fragments one at a time starves it and produces idle frames between speech, buffer locally for playback, but coalesce what you send. See the **InteractionInput** message details above and [Optimizing Performance](/guides/optimizing-performance).
* The [Python SDK](/models/build-with-python-sdk) and [Pipecat](/models/introduction/integrations) handle this input shaping for you. This only matters when driving the WebSocket API directly.

### Buffer Management

* Play frames at **25 fps** (40ms per frame)
* The server delivers at realtime 25 fps. Keep a **small jitter buffer** (a few frames); it won't grow on its own
* **Recovery:** if frames back up after a network stall, skip 1 out of every 2 idle frames (`frame_type == 0`) until the buffer shrinks back
* **Never drop speech (`1`), start-of-speech (`3`), or fade-out (`2`) frames**
* Tune your target buffer size based on your network conditions. Keep it as low as possible for minimal latency

### Audio and Video Synchronization

Each `InteractionResponse` contains both a JPEG image and a PCM audio chunk. However, the **recommended approach for audio playback** is:

1. **Buffer your TTS/source audio locally** as it arrives from your speech service (for later playback only. You do not need to buffer it to send it to Ojin)
2. **Forward TTS audio to Ojin immediately** as it arrives from your speech service
3. **Wait for the first speech frame** (`frame_type` `1` or `3`) to arrive from Ojin
4. **Start playing your buffered TTS audio** at that moment
5. **Stop audio playback** when speech ends, the first idle or fade-out frame (`frame_type` `0` or `2`) after speech
6. **Render video** from every frame regardless of type

Sending is immediate, but playback is gated on the first speech frame. This ensures audio and video stay in sync.

```python
# When TTS audio arrives from your speech service:
speech_audio_buffer.extend(tts_audio_chunk)      # buffer locally for playback
await ojin.send_audio(tts_audio_chunk)            # send to Ojin for lip-sync

# In your video playback loop:
frame = buffer.popleft()

# Speech frames are frame_type 1 (speech) and 3 (start-of-speech).
if frame.frame_type in (1, 3) and not audio_playing:
    start_audio_playback(speech_audio_buffer)     # begin draining the buffer
    audio_playing = True

# Non-speech frames are frame_type 0 (idle) and 2 (fade-out).
if frame.frame_type in (0, 2) and audio_playing:
    stop_audio_playback()
    audio_playing = False

render_video(frame.image)                         # always render the video
```

### Error Handling

* Handle both JSON `ErrorResponse` messages and plain text error strings
* Implement exponential backoff for reconnection
* Monitor server `load` in the `SessionReady` message

### Interruption Handling

* Use `CancelInteraction` for immediate stops (e.g., user interrupts the bot)
* Clear your frame buffer on interruption

***

## Complete Example

```python
import asyncio
import json
import struct
import time
from collections import deque
import numpy as np
import websockets
from dotenv import load_dotenv
import os

load_dotenv()

API_KEY = os.getenv("OJIN_API_KEY", "")
CONFIG_ID = os.getenv("OJIN_CONFIG_ID", "")
URL = f"wss://models.ojin.ai/realtime?config_id={CONFIG_ID}"

SAMPLE_RATE = 16000
FPS = 25
TARGET_BUFFER = 10  # Tune based on your network conditions

def build_audio_message(audio_bytes):
    """Build a binary InteractionInput message."""
    header = struct.pack('!BQI', 1, int(time.time() * 1000), 0)
    return header + audio_bytes

def parse_response(data):
    """Parse a binary InteractionResponse message."""
    fmt = '!B16sQIII'
    hdr_size = struct.calcsize(fmt)
    # The 5th header field is reserved; ignore it.
    is_final, uid_bytes, ts, usage, _reserved, n_payloads = struct.unpack(fmt, data[:hdr_size])

    offset = hdr_size
    image = audio = None
    for _ in range(n_payloads):
        size, ptype = struct.unpack('!IB', data[offset:offset+5])
        offset += 5
        if ptype == 2:
            image = data[offset:offset+size]
        elif ptype == 1:
            audio = data[offset:offset+size]
        offset += size

    # Trailing frame type byte, appended after the payload entries.
    frame_type = data[offset]

    return {
        'is_final': bool(is_final),
        'frame_type': frame_type,    # 0=idle, 1=speech, 2=fade-out, 3=start-of-speech
        'image': image,
        'audio': audio,
    }

async def main():
    headers = {"Authorization": API_KEY}
    # For older websockets versions, use extra_headers instead.
    async with websockets.connect(URL, additional_headers=headers, ping_interval=30) as ws:
        # 1. Wait for SessionReady; the server starts streaming frames immediately after
        msg = json.loads(await ws.recv())
        assert msg["type"] == "sessionReady"
        print(f"Session ready: {msg['payload']}")

        buffer = deque()
        skip_counter = 0
        playback_started = False
        frame_count = 0
        audio_sent = False

        # 2. Receive and process frames. The server streams idle frames
        #    immediately after SessionReady; nothing needs to be sent first.
        async for data in ws:
            if isinstance(data, str):
                msg = json.loads(data)
                if msg.get("type") == "errorResponse":
                    print(f"Error: {msg['payload']}")
                    break
                continue

            frame = parse_response(data)
            buffer.append(frame)
            frame_count += 1

            # Wait for initial buffer before playback
            if not playback_started:
                if len(buffer) >= TARGET_BUFFER:
                    playback_started = True
                    print(f"Buffer filled ({TARGET_BUFFER} frames), starting playback")
                continue

            # Consume one frame
            if buffer:
                play_frame = buffer.popleft()

                # Drop excess idle frames: skip 1 out of 2 when buffer is too large.
                # Only idle (frame_type 0) is safe to drop; keep speech (1),
                # start-of-speech (3), and fade-out (2). Check the frame you are
                # about to drop, not the one you just played: an idle frame is
                # often followed by speech, and dropping that leaves a media gap.
                if len(buffer) > TARGET_BUFFER and buffer[0]['frame_type'] == 0:
                    skip_counter += 1
                    if skip_counter % 2 == 0:
                        buffer.popleft()  # drop one idle frame

                kind = {0: "idle", 1: "speech", 2: "fade-out", 3: "start-of-speech"}.get(
                    play_frame['frame_type'], "?"
                )
                print(f"[{kind}] frame #{frame_count}, buffer={len(buffer)}")

                # In a real app: render play_frame['image'] and play play_frame['audio']

            # Demo: send speech audio after receiving some silence frames
            if frame_count == 50 and not audio_sent:
                t = np.linspace(0, 1.0, SAMPLE_RATE, endpoint=False)
                audio_data = (32767 * 0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.int16)
                chunk_size = SAMPLE_RATE # 1s chunks
                for i in range(0, len(audio_data), chunk_size):
                    chunk = audio_data[i:i + chunk_size]
                    await ws.send(build_audio_message(chunk.tobytes()))
                audio_sent = True
                print("Sent 1 second of speech audio")

            if frame_count > 200:
                break

asyncio.run(main())
```

***

## Troubleshooting

Symptoms, causes, and fixes, connection and auth, a starved/idle mouth, choppy playback, latency, and frame lag (`speech_filter_amount`), live in the global [Troubleshooting guide](/guides/troubleshooting), where each entry is tagged 🟢 SDK-handled or 🟠 your setup.

***

## Example Implementation

A complete working Python example integrating Ojin Human Portrait with a speech-to-speech service (Hume EVI) is available here:

[**github.com/journee-live/speech-to-video-samples/tree/main/samples**](https://github.com/journee-live/speech-to-video-samples/tree/main/samples) (includes Hume STS → Human Portrait walkthroughs)

The repository demonstrates the full integration pattern: microphone capture → STS service → TTS audio → Ojin lip-sync → synchronized video and audio playback at 25fps. It includes the buffer management and frame handling approach described in [Best Practices](#best-practices) above.

***


# Using a Persona Template

Learn how to create a persona out of a template to get started ASAP with your application. Templates work for both [Human Portrait](/models/human-portrait) and [Human Presence](/models/human-presence).

## Prerequisites

* An Ojin account with an active API key

## Creating a Persona through the Dashboard

The simplest way to create a persona is through the Ojin Dashboard:

1. Log in to the [Ojin Dashboard](https://ojin.ai)
2. Navigate to the [**Human Portrait**](https://ojin.ai/models/ojin/human-portrait) or [**Human Presence**](https://ojin.ai/models/ojin/human-presence) section
3. Navigate to the **Configs** sub-section
4. Select a persona template and press **Copy Template**
5. Open the newly created model configuration and save the **Model Config ID** parameter which will be used by your application
6. You can now integrate it through the [model API endpoints](/models/introduction/api)

## Next Steps

Once your persona is ready, you can:

<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>Integration Guide</strong></td><td>Integrate your persona using Pipecat or the WebSocket API.</td><td><a href="/models/introduction/integrations">Get started</a></td></tr><tr><td><strong>API Reference</strong></td><td>Explore the complete API documentation.</td><td><a href="/models/introduction/api">API reference</a></td></tr></tbody></table>


# Creating a custom persona

Create a persona by uploading a reference image when you add a configuration in the [Ojin Dashboard](https://ojin.ai/dashboard). The flow is the same for both face models.

## Creating a configuration through the Dashboard

1. Log in to the [Ojin Dashboard](https://ojin.ai/dashboard)
2. Select your model: [**Human Portrait**](/models/human-portrait) or [**Human Presence**](/models/human-presence)
3. Navigate to the **Configs** sub-section
4. Press **New Configuration** to create a new configuration
5. Fill in the required fields and upload a reference image; follow your model's reference-image best practices for the best result
6. Click **Create Configuration**
7. Open your new configuration and copy the **Model Config ID** for your application

{% hint style="info" %}
Reference-image guidance is specific to each model. See **Best practices** for [Human Portrait](/models/human-portrait/best-practices) or [Human Presence](/models/human-presence/best-practices).
{% endhint %}

## Next Steps

* [**Get started**](/models/introduction/integrations): integrate with the Python SDK, Pipecat, or WebSocket
* [**API Reference (advanced)**](/models/introduction/api): the raw WebSocket protocol


# Human Portrait

A cost-effective, lifelike persona model that transforms reference images into natural animated personas

## Overview

The ojin/human-portrait model creates realistic, expressive digital humans from a single reference image. It excels at producing natural facial animations, lip-syncing, and emotional expressions that bring your persona to life with synchronized speech, at the best price for a real-time talking face.

{% columns %}
{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-2ae32dfb9ddc926c15020421a86166ba00117d5c%2Fportrait-1.avif?alt=media" alt="" height="200" width="200"><figcaption><p><strong>Hana</strong>, virtual assistant</p></figcaption></figure>
{% endcolumn %}

{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-33872aadb188484db50cc3930f288a48cc45440a%2Fportrait-2.avif?alt=media" alt="" height="200" width="200"><figcaption><p><strong>Marcus</strong>, tutor</p></figcaption></figure>
{% endcolumn %}

{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-d5ee948594ce4eea402a8441ef1145184229714d%2Fportrait-3.avif?alt=media" alt="" height="200" width="200"><figcaption><p><strong>Asha</strong>, health assistant</p></figcaption></figure>
{% endcolumn %}
{% endcolumns %}

*One model, any persona. Each generated from a single reference image.*

Want the most expressive, lifelike result? [Human Presence](/models/human-presence) is our flagship face model, with generative expressions and natural movement. Both models are driven the same way. See [Build with the Python SDK](/models/build-with-python-sdk).

## Key Features

* **Full persona look control** - Generate a persona based on any image reference, the persona will behave exactly the same
* **No training required** - You don't need to wait for your persona to be ready, as soon as the reference image is uploaded, you can start using it
* **Natural Lip-Syncing** - Precise lip movements synchronized with speech audio
* **Emotional Expressions** - Support for multiple emotional states and expressions
* **Real-Time** - Streams synchronized audio and video at a fixed 25 fps, for live conversation
* **High Resolution** - Support for up to 720p output resolution

## Quick Start

Getting started with ojin/human-portrait is simple:

1. [**Create an API key**](/getting-started/authentication) - Set up authentication for the Ojin platform
2. [**Use a persona template**](/models/introduction/using-persona-template) - Use a persona template to generate your persona in seconds
3. [**Integrate with your application**](/models/introduction/integrations) - Recommended: the [Python SDK](/models/build-with-python-sdk) or Pipecat

## Use Cases

* **Virtual Assistants** - Create responsive customer service personas
* **Educational Content** - Develop engaging tutors and instructors
* **Entertainment** - Produce animated characters for games and media
* **Presentations** - Transform static slides into dynamic video presentations
* **Healthcare** - Build empathetic virtual health assistants


# Best practices

Follow the guidance below for the best results. Your reference image is the foundation of the persona, so a clean, well-framed image gives the most natural output.

## Reference image best practices

* **Image content**:
  * Mouth should be closed, but smiles or subtle expressions are fine
  * The eyes should be looking directly into the camera
  * Keep the expression balanced, the image is used as the base for both the idle loop and speech, so avoid extreme poses
* **Format**: JPEG, PNG, or WebP
* **Lighting**: Even lighting with no harsh shadows
* **Face Position**: The face should be clearly visible and centered
* **Background**: Simple backgrounds work best
* **Accessories**: Avoid sunglasses or items that obscure facial features


# Human Presence

Our flagship face model, a fully expressive, generative presence that goes beyond lip-sync.

## Overview

**Human Presence** is Ojin's highest-quality real-time face model. It generates a fully expressive *presence*, rich facial expressions and natural movement, including the hands, for the most lifelike, dynamic result.

{% columns %}
{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-873165daaba663308e4e1d6b69fdb605769b01c2%2Fpresence-1.avif?alt=media" alt="" height="248" width="150"><figcaption><p><strong>Seraphina</strong>, game character</p></figcaption></figure>
{% endcolumn %}

{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-4cdd9899fcd5acf6569aa17ce5da057a21691142%2Fpresence-2.avif?alt=media" alt="" height="248" width="150"><figcaption><p><strong>Mathieu</strong>, chef</p></figcaption></figure>
{% endcolumn %}

{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-c0fdc09a0d27f92d3ac82c250be2e062ede76d2f%2Fpresence-3.avif?alt=media" alt="" height="248" width="150"><figcaption><p><strong>Dr. Robert</strong>, clinician</p></figcaption></figure>
{% endcolumn %}

{% column %}

<figure><img src="https://716616036-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FS2Pj5IbDX90dps067L35%2Fuploads%2Fgit-blob-59714cc208b91adb81980aca2f72ca0e24e575f2%2Fpresence-4.avif?alt=media" alt="" height="248" width="150"><figcaption><p><strong>Olivia</strong>, brand spokesperson</p></figcaption></figure>
{% endcolumn %}
{% endcolumns %}

*Generative expression and movement (including the hands) across four personas, photoreal and stylised alike.*

Feed it speech audio and it streams back a synchronized talking-avatar video in real time. Pick the model with its `config_id`. New here? See the [Introduction](/models/introduction) to compare Ojin's face models and choose the right one.

## Key features

* **Fully expressive presence**: generative facial expressions and natural movement, beyond lip-sync
* **Real-time**: conversation-ready, streamed as synchronized audio + video
* **One config, any integration**: drive it from the Python SDK, Pipecat, or the raw WebSocket API
* **Model slug**: `ojin/human-presence`

## Quick Start

The fastest way to build with Presence is the **Python SDK** or **Pipecat**:

1. [**Create an API key**](/getting-started/authentication): set up authentication for the Ojin platform
2. **Get a Human Presence `config_id`** from the [dashboard](https://ojin.ai/dashboard). See [Best practices](/models/human-presence/best-practices) for reference-image guidance
3. [**Integrate with your application**](/models/introduction/integrations): recommended: Python SDK or Pipecat

## Use Cases

* **Brand & spokesperson**: a premium, lifelike presence that represents your brand
* **High-touch support & sales**: expressive agents for moments that matter
* **Education & training**: engaging tutors with natural expression and movement
* **Interactive experiences**: installations, kiosks, and showcases that feel alive


# Best practices

Follow the guidance below for the best results. The higher the quality and the more detail your reference image has, the richer and more lifelike the generated video.

## Reference image best practices

* **Resolution**: Use a **768 × 1280** reference image for the best results.
* **Framing**: A **medium shot** (more of the body and hands in frame, best for rich, expressive motion) through to a **close-up** (best for fine facial detail) is the recommended range for this model. Choose where you sit in that range depending on whether you want more motion or more detail.
* **Avoid full-body shots**: At full-body distance the face and hands become too small and the result loses detail, stay within the medium-shot-to-close-up range.
* **Hands and body**: Show the hands in a passive, resting state and the body in a relaxed, resting position. The model generates natural body and hand movement from there.
* **Backgrounds can animate**: A good, detailed background can come to life too. You are not limited to a plain backdrop. Try to avoid other visible faces close to your persona's face.
* **Keep the face clearly visible**: A clearly visible, reasonably centered face is the main guideline; beyond that you have freedom over styling, pose, and background.
* **Format**: JPEG, PNG, or WebP.


# Python SDK

Drive Ojin's face models (Human Presence and Human Portrait) straight from Python. Feed in your TTS audio; get back a synchronized, lip-synced talking-avatar stream.

The [`ojin-client`](https://pypi.org/project/ojin-client/) SDK is the fastest way to add a real-time talking avatar to a Python app or voice agent. You bring the words (any TTS); the SDK streams back a face that speaks them (perfectly in sync) handling buffering, the playback clock, resampling, and barge-in for you.

{% hint style="info" %}
**Just want a complete agent?** If you'd rather not assemble a pipeline, the [Human Agent](/apps/overview) gives you a full speech-in, speech-out avatar with a single line of embed code. Already on [Pipecat](https://github.com/pipecat-ai/pipecat)? Jump to the [Pipecat integration](/models/introduction/integrations).
{% endhint %}

## Requirements

* **Python 3.10+**
* An **Ojin API key**, [get yours here](/getting-started/authentication)
* A face-model **config ID**, a Presence or Portrait configuration from your [dashboard](https://ojin.ai/dashboard)

New accounts start with **$10 in free credits**.

## Install

With [uv](https://docs.astral.sh/uv/) (recommended):

```bash
uv add "ojin-client[stv]"
```

Or with pip:

```bash
pip install "ojin-client[stv]"
```

The `[stv]` extra pulls in the speech-to-video helpers (`numpy`, `opencv-python-headless`, `soxr`) that handle resampling, decoding, and the sync math for you.

## Authenticate

The client needs your API key and a configuration ID. Keep both out of source. Read them from the environment:

```bash
export OJIN_API_KEY="…"      # from your Ojin account
export OJIN_CONFIG_ID="…"    # a Presence or Portrait configuration to drive
```

```python
import os
from ojin.stv import OjinSTVClient

client = OjinSTVClient(
    api_key=os.environ["OJIN_API_KEY"],
    config_id=os.environ["OJIN_CONFIG_ID"],
)
```

Or let the SDK load and validate them for you. `resolve_credentials()` reads an optional `.env` file and raises a clear error if either value is missing:

```python
from ojin import resolve_credentials
from ojin.stv import OjinSTVClient

creds = resolve_credentials()  # reads OJIN_API_KEY + OJIN_CONFIG_ID
client = OjinSTVClient(api_key=creds.api_key, config_id=creds.config_id)
```

By default the client connects to `wss://models.ojin.ai/realtime`.

{% hint style="warning" %}
Never hardcode your API key in source or commit it to version control.
{% endhint %}

## Pick your model

The SDK is **identical for both face models**; the `config_id` you pass selects which one runs.

| A `config_id` from…         | You get                                                                                   | Choose it when                                  |
| --------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------- |
| a **Human Presence** config | the flagship, fully expressive generative presence, rich expressions and natural movement | quality and expressiveness matter most          |
| a **Human Portrait** config | a cost-effective, real-time lip-synced talking face                                       | you want a great talking face at the best price |

Create configurations in the [dashboard](https://ojin.ai/dashboard). The code below is the same either way; only the `config_id` changes.

## Two clients, one package

|             | `OjinSTVClient` (high-level)                               | `OjinClient` (low-level)                  |
| ----------- | ---------------------------------------------------------- | ----------------------------------------- |
| Import      | `from ojin.stv import OjinSTVClient`                       | `from ojin.ojin_client import OjinClient` |
| You give it | TTS audio at **any** sample rate                           | 16 kHz mono `int16` PCM                   |
| You get     | synced `STVAudioFrame` + `STVVideoFrame` on a 25 fps clock | raw protocol messages (JPEG + PCM)        |
| It handles  | buffering, playback clock, resampling, barge-in re-sync    | just the WebSocket + wire protocol        |

Most applications want **`OjinSTVClient`**. Reach for `OjinClient` only when you're writing your own playback/sync layer.

## Quickstart

Feed the avatar one utterance of TTS audio and consume the synchronized stream it speaks back.

```python
import asyncio
import os
import wave

from ojin.stv import OjinSTVClient, STVEvent, STVAudioFrame, STVVideoFrame


async def main() -> None:
    ready = asyncio.Event()
    done = asyncio.Event()

    async with OjinSTVClient(
        api_key=os.environ["OJIN_API_KEY"],
        config_id=os.environ["OJIN_CONFIG_ID"],
    ) as client:
        # Events may be sync or async; register with a decorator or add_listener().
        client.add_listener(STVEvent.SESSION_READY, lambda **_: ready.set())
        client.add_listener(STVEvent.BOT_STOPPED_SPEAKING, lambda **_: done.set())

        @client.on(STVEvent.ERROR)
        def _on_error(message: str, **_):
            print("ojin error:", message)

        # Wait until the avatar session is live (race-free: skip if already up).
        if not client.is_connected:
            await ready.wait()

        # Send one utterance of mono int16 PCM TTS audio at ANY sample rate:
        # the SDK resamples the copy it sends to the server for lip-sync.
        with wave.open("hello.wav", "rb") as wav:  # a mono WAV
            await client.say(
                wav.readframes(wav.getnframes()),
                sample_rate=wav.getframerate(),
                num_channels=1,
            )

        # Consume the synchronized 25 fps audio + video stream.
        async def consume() -> None:
            async for frame in client.output_stream():
                if isinstance(frame, STVVideoFrame) and frame.rgb is not None:
                    ...  # frame.rgb = width*height*3 RGB bytes, render it
                elif isinstance(frame, STVAudioFrame):
                    ...  # frame.pcm = int16 PCM @ frame.sample_rate, play it

        consumer = asyncio.create_task(consume())
        await done.wait()           # the avatar finished the turn
        await asyncio.sleep(0.5)    # let the tail frames drain
        consumer.cancel()


asyncio.run(main())
```

`say()` is a one-shot helper for `start_turn()` + `send_tts_audio()`. For a **live agent**, call `start_turn()` once per utterance and stream chunks with `send_tts_audio()` as your TTS produces them, the user hears the exact audio you sent, lip-synced to the avatar.

{% hint style="success" %}
**The SDK shapes the input for you.** Feed audio at whatever cadence your TTS produces, even tiny 40 ms fragments. `OjinSTVClient` primes a \~1 s lead after each `start_turn()` and coalesces your audio into large chunks before forwarding it, so the inference head never starves and lip-sync stays stable. It also paces `output_stream()` to realtime 25 fps with audio and video already in sync. You don't manage input buffering, the playback clock, or frame-dropping yourself. See [Optimizing Performance](/guides/optimizing-performance) for how it works and how to tune it.
{% endhint %}

## Events you'll use

Register handlers with `@client.on(STVEvent.X)` or `client.add_listener(STVEvent.X, cb)`. Handlers may be sync or async; a failing handler never breaks the playback loop.

| Event                  | Fires when                                 |
| ---------------------- | ------------------------------------------ |
| `SESSION_READY`        | the session is live and ready for audio    |
| `BOT_STARTED_SPEAKING` | the first speech frame of a turn is played |
| `BOT_STOPPED_SPEAKING` | a turn has finished playing                |
| `INTERRUPTED`          | a barge-in was accepted                    |
| `ERROR`                | a transport or server error occurred       |
| `CLOSED`               | the session has been torn down             |

## Where to go next

<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>Runnable examples</strong></td><td>Realtime streaming and render-to-MP4, ready to clone.</td><td><a href="https://github.com/ojinai/python-sdk/tree/main/examples">https://github.com/ojinai/python-sdk/tree/main/examples</a></td></tr><tr><td><strong>Build a voice agent</strong></td><td>Drop the avatar into a Pipecat pipeline.</td><td><a href="/models/introduction/integrations">Get started</a></td></tr></tbody></table>

* **Best practices**: [working-pipeline patterns](/models/build-with-python-sdk/python-sdk-best-practices) for browser output and WebRTC backends
* **Choose a face model**: [Human Presence](/models/human-presence) (flagship) or [Human Portrait](/models/human-portrait) (cost-effective)
* **Tune latency & stability**: [Optimizing Performance](/guides/optimizing-performance) and the [Troubleshooting guide](/guides/troubleshooting)
* **Full API surface**: the complete client reference lives in the [`ojin-client` README](https://github.com/ojinai/python-sdk) and on [PyPI](https://pypi.org/project/ojin-client/)
* **Starter examples**: `02-realtime-speech-to-video` (live streaming) and `01-speech-to-video-mp4-generator` (render to file)


# Best Practices

The key things to get right for a smooth, low-latency avatar pipeline with OjinSTVClient, whether you're prototyping straight to a browser or running a backend that relays media over WebRTC.

## Essentials (every setup)

* **Run it server-side.** The SDK connects to Ojin over a **server-to-server** WebSocket. Run it in a backend process, never on an end-user device, and keep your API key off the client. In production, run it in **US East**, close to Ojin's inference, for the lowest latency.
* **Feed audio as your TTS produces it.** Call `start_turn()` once per utterance, then `send_tts_audio()` for each chunk as it arrives, even tiny 40 ms fragments. The SDK shapes the feed (primes a \~1 s lead, then coalesces to ≥400 ms) so the model never starves. Don't batch the whole utterance yourself.
* **Present frames as they arrive. Don't re-sync.** `output_stream()` is already paced to realtime **25 fps**, with audio and video in sync and a small jitter buffer. Forward each frame the moment it arrives; never hold audio to wait for a video frame, and don't add your own A/V sync layer.
* **Handle the lifecycle.** Wait for `SESSION_READY` before relying on the stream; use `BOT_STARTED_SPEAKING` / `BOT_STOPPED_SPEAKING` for UI state; on `ERROR`, retry with backoff (e.g. `NO_BACKEND_SERVER_AVAILABLE`).
* **Barge-in with `interrupt()`.** It fades the current audio and cancels the turn server-side, just call it when the user starts talking.

{% hint style="info" %}
On [Pipecat](/models/introduction/integrations)? `pipecat-ojin`'s `OjinVideoService` already wires all of this into a pipeline and pushes frames into your transport, start there instead of hand-rolling the loop.
{% endhint %}

## Setup A. Local, output to a browser (prototyping)

Run the SDK in a local Python process and stream frames to a browser you control (e.g. over a WebSocket to a `<canvas>`). Great for demos and development; **not** for production over real networks.

* **Forward the JPEG straight to the browser.** Use a `PassthroughDecoder` so the SDK skips decoding. Read the raw JPEG from `STVVideoFrame.source_bytes` (\~60 KB) and draw it to a `<canvas>`/`<img>` as it arrives. (Need raw RGB in Python instead? Keep the default decoder and read `frame.rgb`.)
* **Play audio at the rate you fed.** Run the browser `AudioContext` at the **same sample rate** as the audio you sent (`STVAudioFrame.sample_rate`). You can feed higher-quality TTS (e.g. 24 kHz) for better sound, the SDK plays back your original audio while lip-sync uses a 16 kHz copy.
* **Present as they come.** Draw each video frame and queue each audio chunk the instant it arrives, no re-sync; the stream is already aligned.

```python
client = OjinSTVClient(
    api_key=..., config_id=...,
    decoder=PassthroughDecoder(),     # forward JPEG; the browser decodes it
)
async for frame in client.output_stream():
    if isinstance(frame, STVVideoFrame):
        await ws.send_bytes(frame.source_bytes)        # raw JPEG -> draw on a canvas
    elif isinstance(frame, STVAudioFrame):
        await ws.send_bytes(frame.pcm)                 # play at frame.sample_rate
```

## Setup B. Backend, relay to a WebRTC service (production)

Run the SDK on a backend and relay the media to your users over a **WebRTC** service (LiveKit, Daily, mediasoup, …), which absorbs packet loss, jitter, and varying networks that a raw WebSocket can't.

* **Deploy in US East**, close to Ojin's inference.
* **Push frames into the transport with their `pts`.** Each `STVAudioFrame` / `STVVideoFrame` carries a `pts` timestamp, hand it to your WebRTC audio/video tracks and let the transport sync them for the end user. Don't re-sync yourself.
* **Push, don't poll, when you can.** Inject a custom `STVOutput` sink to write frames straight into your transport instead of draining `output_stream()`, for lower latency and less glue. (This is exactly what `pipecat-ojin` does.)
* **Match the video track to the model's frame size**: read `STVVideoFrame.width` / `height` and configure your outgoing track to match.
* **Deliver to end users over WebRTC, not the raw WebSocket.** Keep the Ojin WebSocket strictly server-to-server.

```python
async for frame in client.output_stream():
    if isinstance(frame, STVVideoFrame):
        video_track.push(frame.rgb, pts=frame.pts)     # feed your WebRTC track
    elif isinstance(frame, STVAudioFrame):
        audio_track.push(frame.pcm, pts=frame.pts)     # transport handles A/V sync
```

## See also

* [Build with the Python SDK](/models/build-with-python-sdk), install, auth, events, full quickstart
* [Optimizing Performance](/guides/optimizing-performance), the audio-feeding contract and tuning
* [Troubleshooting](/guides/troubleshooting), symptoms, causes, and fixes
* [API Reference (advanced)](/models/introduction/api), the raw WebSocket protocol


# Optimizing Performance

How to feed audio and play back frames for stable, low-latency lip-sync, and how the Python SDK and Pipecat handle most of it for you.

{% hint style="success" %}
**Using the** [**Python SDK**](/models/build-with-python-sdk) **or** [**Pipecat**](/models/introduction/integrations)**?** They already implement everything on this page, audio feeding, the playback clock, buffering, and audio/video sync. Read on if you want to tune them, or if you're building directly on the [raw WebSocket API](/models/introduction/api).
{% endhint %}

Each section below is tagged **🟢 SDK-handled** (automatic with the SDK or Pipecat, only your concern on the raw WebSocket API) or **🟠 Your setup** (you own it regardless of integration, deployment, network, credentials).

## Feed audio for stable, low-latency lip-sync

🟢 **SDK-handled**. `OjinSTVClient` shapes the feed automatically; the contract below is only for the raw WebSocket API.

The model runs a fixed **25 fps** virtual timeline and needs your audio **input to stay slightly ahead** of that timeline to generate speech frames continuously. If you forward tiny TTS fragments one at a time (for example the \~40 ms chunks many TTS providers stream) your input rate matches the output rate, the model's lead never builds, and it **starves**: it falls back to idle frames in between, and lip-sync skips or drifts.

The rule is **realtime cadence with a cushion**, not per-fragment forwarding:

1. **Prime \~1 second of audio** at the start of each turn before you rely on realtime delivery.
2. Then send the **largest chunks you can**: coalesce queued fragments into **\~400 ms** sends (staying under the 512 KB message cap).
3. Stay truly realtime: never wait for the whole utterance before you start.

{% hint style="success" %}
**`OjinSTVClient` shapes the input for you.** The SDK accumulates a \~1 s initial chunk after each `start_turn()` to establish the lead, then coalesces your TTS into ≥400 ms sends automatically, so the inference head never starves. You just feed audio as your TTS produces it; the SDK takes care of the input shape to optimize latency and stability (tunable via the `server_feed_*` fields on `STVConfig`). The contract above only matters if you drive the raw WebSocket API yourself.
{% endhint %}

```python
# With the SDK, feed audio as it arrives; shaping is automatic.
await client.start_turn()
async for chunk in tts_stream:          # e.g. 40 ms ElevenLabs / Cartesia chunks
    await client.send_tts_audio(chunk.pcm, sample_rate=chunk.rate, num_channels=1)
```

## Play back at realtime

🟢 **SDK-handled**: `output_stream()` is paced and buffered for you.

The model delivers frames at **realtime 25 fps**, so your buffer doesn't grow on its own. You just keep a **small buffer to absorb network jitter** (a few frames). How you consume them depends on your integration:

* **SDK / Pipecat**: `output_stream()` is already paced to realtime **25 fps with audio and video in sync**, and the client maintains the small jitter buffer for you (`initial_buffer_frames`). You don't manage buffering at all.
* **Raw WebSocket**: keep a small jitter buffer of a few frames before starting playback. If frames ever back up (e.g. a brief network stall, then a burst), trim idle frames (`frame_type == 0`) to recover, never drop speech (`1`), start-of-speech (`3`), or fade-out (`2`) frames. See [Buffer Management](/models/introduction/api#buffer-management) and [Audio and Video Synchronization](/models/introduction/api#audio-and-video-synchronization) in the API Reference.

{% hint style="info" %}
**Don't re-sync the SDK's output yourself.** `output_stream()` is already paced to 25 fps with audio and video aligned, so just present each frame **as it arrives**, never hold audio to wait for a video frame:

* **Playing directly (e.g. in a browser):** play audio and video frames as they come. No audio-clock or A/V re-sync logic needed.
* **Forwarding to a media transport (e.g. WebRTC):** each `STVAudioFrame` and `STVVideoFrame` carries a `pts` timestamp, pass it through and let the transport use it for sync. Don't build your own sync layer on top.

Building your own loop on the **raw WebSocket** instead? That's where the audio-as-clock technique applies. See [Audio and Video Synchronization](/models/introduction/api#audio-and-video-synchronization) in the API Reference.
{% endhint %}

## Where to run

🟠 **Your setup**: deployment is yours to get right, with the SDK or the raw API.

The realtime API is a **WebSocket built for server-to-server delivery over a stable connection**. It is not meant to run on an end-user device on flaky Wi‑Fi or mobile networks.

* **Run the client on a backend server**, not in the browser or on the user's device. This also keeps your API key off the client.
* **Deploy in US East**, close to Ojin's inference, for the lowest round-trip latency.
* **Deliver the final media to end users over a realtime transport** built for varying network conditions (typically **WebRTC**) rather than exposing the raw WebSocket to them.

## Play back higher-quality audio

🟢 **SDK-handled**: the SDK plays back your original audio automatically.

`OjinSTVClient` plays back the **original audio you fed it**. It only resamples a separate 16 kHz copy to send to the model for lip-sync. Your listeners hear exactly the audio you sent.

So you can feed **higher-quality TTS** (for example 24 kHz instead of 16 kHz) for better sound while lip-sync still works on the 16 kHz copy. Just make sure your player runs at the **same sample rate you fed**. `STVAudioFrame.sample_rate` reports it per frame.

## Relay frames without re-encoding

🟢 **SDK-handled**: the SDK exposes the raw JPEG (`source_bytes`) for you.

If you forward video frames to a browser or media transport that decodes JPEG itself, skip the decode:

* The **default decoder** populates `STVVideoFrame.rgb` (decoded RGB) and also keeps the raw JPEG in `STVVideoFrame.source_bytes`.
* A **`PassthroughDecoder`** skips the decode entirely: `rgb` is `None` and `output_stream()` won't decode for you. Read the raw JPEG from `source_bytes` and relay it directly, no re-encode.

## Keep latency low

🟢 **SDK-handled** · 🟠 **Your setup** (deployment)

Keep your buffers as small as your network allows; large buffers add latency. On the raw API, tune your target buffer size by watching it during playback: low enough to minimise latency, high enough to absorb jitter without starving playback. With the SDK, the relevant knobs live on [`STVConfig`](/models/build-with-python-sdk) (`initial_buffer_frames`, `max_buffered_video_frames`).

## Next steps

<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>Troubleshooting</strong></td><td>Symptoms, causes, and fixes.</td><td><a href="/guides/troubleshooting">Troubleshooting</a></td></tr><tr><td><strong>Python SDK Best Practices</strong></td><td>Working pipelines for browser output and WebRTC backends.</td><td><a href="/models/build-with-python-sdk/python-sdk-best-practices">Best Practices</a></td></tr></tbody></table>


# Troubleshooting

Common symptoms when building real-time avatars, with their cause and fix.

{% hint style="info" %}
**🟢 SDK-handled**: the [Python SDK](/models/build-with-python-sdk) and [Pipecat](/models/introduction/integrations) take care of this for you; it surfaces only on the raw WebSocket API (or if you turn a feature off). The simplest fix is usually "use the SDK."

**🟠 Your setup**: you own this regardless of integration: deployment, network, credentials, capacity, or raw-API tuning.
{% endhint %}

## Jump to a symptom

* [Mouth barely moves or only idle animation](#mouth-barely-moves-or-only-idle-animation)
* [Lip-sync drifts out of sync with the audio](#lip-sync-drifts-out-of-sync-with-the-audio)
* [Choppy or crackling playback](#choppy-or-crackling-playback)
* [No video frames at all](#no-video-frames-at-all)
* [Growing latency over time](#growing-latency-over-time)
* [Frame lag during speech on the raw API](#frame-lag-during-speech-on-the-raw-api)
* [No backend servers available](#no-backend-servers-available)
* [Connection or authentication fails](#connection-or-authentication-fails)
* [Interruptions look abrupt or the avatar keeps speaking after a barge-in](#interruptions-look-abrupt-or-the-avatar-keeps-speaking-after-a-barge-in)

## Mouth barely moves or only idle animation

🟢 **SDK-handled**

**Cause:** the model is **starving**. You're sending too little audio, or forwarding tiny TTS fragments one at a time, so the model can't sustain speech at 25 fps and emits idle frames in between.

**Fix:** feed audio with a lead: **prime \~1 s, then send the largest chunks you can (\~400 ms)**. See [Optimizing Performance → Feed audio](/guides/optimizing-performance#feed-audio-for-stable-low-latency-lip-sync). `OjinSTVClient` already does this for you, so the simplest fix is to drive the model through the [Python SDK](/models/build-with-python-sdk) or [Pipecat](/models/introduction/integrations).

## Lip-sync drifts out of sync with the audio

🟢 **SDK-handled**

**Cause:** on the SDK path, you're adding your own A/V re-sync on top of an already-synced stream; or on the raw API, video is played off the audio clock or frames were reordered during async decode.

**Fix:** with the SDK or Pipecat, `output_stream()` is already aligned at 25 fps. **don't re-sync it yourself**. Present each frame as it arrives, or pass each frame's `pts` to your media transport (e.g. WebRTC) and let it handle sync. If you build your own loop on the raw API, drive playback from the audio clock and keep decode order stable. See [Audio and Video Synchronization](/models/introduction/api#audio-and-video-synchronization) in the API Reference.

## Choppy or crackling playback

🟢 **SDK-handled**

**Cause:** audio isn't playing at a steady rate, or the event loop is blocked.

**Fix:** play audio gaplessly at a steady rate; never stop it just because a video frame is late. Keep your frame handlers light; heavy synchronous work stalls the playback tick. See [Optimizing Performance → Play back at realtime](/guides/optimizing-performance#play-back-at-realtime).

## No video frames at all

🟢 **SDK-handled**

**Cause:** you haven't received `SESSION_READY` / `SessionReady` yet, or (SDK) you're reading `frame.rgb` while using a `PassthroughDecoder`.

**Fix:** wait for the session-ready signal before sending audio. With the SDK, the default decoder populates `frame.rgb`; a `PassthroughDecoder` leaves `rgb` as `None` and delivers the raw JPEG in `source_bytes` instead. Read that, or keep the default decoder. See [Optimizing Performance → Relay frames](/guides/optimizing-performance#relay-frames-without-re-encoding).

## Growing latency over time

🟢 **SDK-handled** · 🟠 **Your setup** (deployment)

**Cause:** your buffer is too large, or frames backed up after a network stall. The model delivers at realtime 25 fps, so the buffer shouldn't grow on its own. Keep only a small jitter buffer.

**Fix:** keep the buffer small (a few frames). If frames do back up on a poor connection, trim idle frames (`frame_type == 0`) to recover; never drop speech, start-of-speech, or fade-out frames. The SDK keeps the buffer small for you. Run the client on a backend in **US East** over a stable connection. See [Optimizing Performance → Where to run](/guides/optimizing-performance#where-to-run).

## Frame lag during speech on the raw API

🟠 **Your setup** (raw-API tuning)

**Cause:** the model's smoothing filter is adding responsiveness lag on the raw WebSocket protocol.

**Fix:** reduce the `speech_filter_amount` parameter; lower is more responsive but less smooth. (The SDK paces output for you, so this only applies when driving the raw API.)

## No backend servers available

🟠 **Your setup**

**Cause:** inference capacity is momentarily exhausted.

**Fix:** retry shortly with backoff. On the SDK this surfaces as an `ERROR` event with code `NO_BACKEND_SERVER_AVAILABLE`; on the raw API as an `ErrorResponse` (or plain-text message) with `BACKEND_UNAVAILABLE`.

## Connection or authentication fails

🟠 **Your setup**

**Fix:**

* ✓ Verify your API key and `config_id`, and that the config exists in your [dashboard](https://ojin.ai/dashboard)
* ✓ The `Authorization` header must use the **raw API key** (no `Bearer` prefix)
* ✓ Ensure your network allows WebSocket connections on port 443

## Interruptions look abrupt or the avatar keeps speaking after a barge-in

🟢 **SDK-handled**

**Cause:** frames already sent or buffered keep playing after you cancel.

**Fix:** the SDK's `interrupt()` fades audio and cancels server-side for you. On the raw API, pick an interruption strategy for your latency/smoothness trade-off: clear the buffer for an instant cut, or keep playing video while stopping audio for a smoother look. See [Interruption Handling](/models/introduction/api#interruption-handling) in the API Reference.

***

Still stuck? [Contact support](/getting-started/support).


