# Quickstart

URL: https://www.atmee.ai/docs/v2/api/quickstart

> From an API key to a live avatar on your own page: one server file and one HTML file, with no packages to install.

This page builds the smallest working Behavior-driven Full-stack app: Atmee runs the conversation,
your server starts and ends the session with your key, and your page joins the room with
`livekit-client`. How the pieces fit is in [How the API works](/docs/v2/api/overview).

- [Or have an AI agent build it](/docs/v2/agents/skills/build-avatar-app): The atmee plugin's build-avatar-app skill writes this same app with you, in Claude Code, Codex or Cursor.

## Before you start

* **An Atmee account on Starter or above.** Atmee V2 needs Growth or above and an invite code.
* **An avatar your account owns** that has finished building, on either model. No avatar yet?
  [Create one](/docs/v2/api/create-avatar#create-with-json) through the API, or in Studio.
* **Node.js 20.6 or later.** The server below uses no packages.

You don't pass a model or a mode. The avatar's [model](/docs/v2/getting-started/models) decides how it
looks and what a minute costs, and the endpoint decides the mode: this page uses Behavior-driven
Full-stack. The other modes are in [Choose your integration](/docs/v2/api/choose-integration).

## Create a key

In **Studio → API**, click **Create API Key**. Name it after the app, keep the default `sessions`
scope, bind it to your avatar, and set a spend cap. Copy the secret, which is shown once, and put
it in a `.env` file next to your server as `ATMEE_API_KEY`. Scopes and caps are in
[API keys](/docs/v2/api/api-keys).

![Studio's API page, with the Create API Key button on the API Keys card](/docs/screenshots/studio-api-page.png)

Or, from a terminal, with the `atmee` CLI signed in as the account's owner or an admin. `--out`
writes `ATMEE_API_KEY=...` into `.env` and never prints the secret:

```bash
atmee keys create --name "quickstart" --avatar <avatar-id> --spend-cap 50 --out .env
```

## Pick an avatar

List your avatars with the key:

```bash
curl https://api.atmanity.us/v1/avatars \
  -H "X-Api-Key: $ATMEE_API_KEY"
```

Each entry in `items` has an `id`, an `engine` and `readyToChat`. Pick one with
`"readyToChat": true` (with `"engine": "v2"` if your account has Atmee V2), and add its id to
`.env` as `AVATAR_ID`. In Studio, the id is the UUID in the avatar's editor address,
`/studio/avatars/<id>/edit`.

## Start a session

Try the call once from your terminal. It starts a real, billed session:

```bash
curl -X POST https://api.atmanity.us/v1/sessions \
  -H "X-Api-Key: $ATMEE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "avatarId": "<avatar-id>", "maxDurationSeconds": 300 }'
```

A `201` means the avatar has already joined the room, usually 15 to 20 seconds after the call:

```json
{
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "serverUrl": "wss://atmanity.livekit.cloud",
  "roomName": "rk-8f14e45f-ceea-467a-9f21-4c2f5b3a91cd",
  "userToken": "eyJhbGciOi…",
  "actualDurationSeconds": 300,
  "requestedDurationSeconds": 300,
  "durationCapped": false,
  "billingMode": "reserve"
}
```

Nobody joins this one, so it ends after 60 seconds as `no_user_joined` and costs nothing.

`actualDurationSeconds`, `requestedDurationSeconds` and `durationCapped` aren't always there:
`requestedDurationSeconds` only when you sent `maxDurationSeconds`, and `actualDurationSeconds`
only when the session has a length cap. A session with `billingMode` `metered` and no cap runs
until your credits or the key's spend cap run out, so don't start a countdown from a missing value.

| Field                | Notes                                                                                                                                                                                                                |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `avatarId`           | Required. An avatar the key may use.                                                                                                                                                                                 |
| `maxDurationSeconds` | Optional, 1 to 14,400; longer is refused with `422`. Within that range it is cut to the key's, the plan's and the balance's limits; `actualDurationSeconds` is what you got.                                         |
| `language`           | Optional. A BCP-47 tag that runs the session in the user's language instead of the avatar's own. Speech recognition and answers follow `en`, `zh-TW` and `ja` today; other tags fall back to detecting the language. |
| `metadata`           | Optional. Up to 50 properties stored with the session, such as your own user or order id.                                                                                                                            |

## Write the server

Two routes: one starts a session and hands the page what it needs to join, the other ends it. The
key never leaves this file. Save it as `server.mjs`:

```js
// server.mjs: the only place the key exists. Node 20.6+, no packages.
import { readFile } from "node:fs/promises";
import http from "node:http";

const API = "https://api.atmanity.us";
const { ATMEE_API_KEY, AVATAR_ID } = process.env;
const mine = new Set(); // sessions this server started

// Most errors are JSON with an `error` code, but a wrong key or an avatar the
// key may not use answers 401 or 403 as plain text or with no body.
async function read(r) {
  const text = await r.text();
  try {
    return text ? JSON.parse(text) : {};
  } catch {
    return { message: text };
  }
}
const errorCode = (r, body) =>
  typeof body.error === "string" ? body.error : `http_${r.status}`;

function atmee(method, path, body) {
  return fetch(API + path, {
    method,
    headers: { "X-Api-Key": ATMEE_API_KEY, "Content-Type": "application/json" },
    body: body && JSON.stringify(body),
  });
}

http
  .createServer(async (req, res) => {
    const json = (status, data) =>
      res
        .writeHead(status, { "Content-Type": "application/json" })
        .end(JSON.stringify(data));

    if (req.method === "GET" && req.url === "/") {
      res.writeHead(200, { "Content-Type": "text/html" });
      return res.end(await readFile("index.html"));
    }

    // Start. Every call is a billed session: in a real app, put this behind your sign-in.
    if (req.method === "POST" && req.url === "/api/session") {
      const r = await atmee("POST", "/v1/sessions", {
        avatarId: AVATAR_ID,
        maxDurationSeconds: 600,
      });
      const body = await read(r);
      if (!r.ok) {
        console.warn(
          "start failed",
          r.status,
          errorCode(r, body),
          body.message,
        );
        return json(r.status, { error: errorCode(r, body) }); // the code only, never the details
      }
      mine.add(body.sessionId);
      // Forward only what the page needs to join and to end. Never the key.
      const { sessionId, serverUrl, userToken } = body;
      return json(200, { sessionId, serverUrl, userToken });
    }

    // End: POST /api/session/<id>/end. Safe to call twice.
    const m = req.url.match(/^\/api\/session\/([0-9a-f-]{36})\/end$/);
    if (req.method === "POST" && m && mine.has(m[1])) {
      const r = await atmee("POST", `/v1/sessions/${m[1]}/end`);
      if (r.ok) mine.delete(m[1]);
      return json(r.status, await read(r));
    }

    res.writeHead(404).end();
  })
  .listen(3000, () => console.log("http://localhost:3000"));
```

The server ends only sessions it started itself, so nobody can end someone else's conversation by
guessing an id. A real app ties each session to its own signed-in user.

## Write the page

Save this as `index.html` next to the server. It loads `livekit-client` 2.22.3 from a CDN; with a
bundler, run `npm i livekit-client@2.22.3` instead.

```html
<video id="avatar" autoplay playsinline width="512" height="512"></video>
<audio id="voice" autoplay></audio>
<button id="start">Talk to the avatar</button>
<button id="stop" hidden>End</button>
<p id="status"></p>

<script type="module">
  import {
    Room,
    RoomEvent,
    Track,
  } from "https://cdn.jsdelivr.net/npm/livekit-client@2.22.3/+esm";

  const $ = (id) => document.getElementById(id);
  let room, endUrl;

  function end() {
    if (!endUrl) return;
    navigator.sendBeacon(endUrl); // your server ends the session, even while the page closes
    endUrl = null;
    room?.disconnect();
    $("stop").hidden = true;
    $("start").hidden = false;
    $("status").textContent = "Ended.";
  }

  $("start").onclick = async () => {
    $("start").hidden = true;
    $("status").textContent = "Connecting…"; // the start takes 15 to 20 seconds
    const res = await fetch("/api/session", { method: "POST" });
    const s = await res.json();
    if (!res.ok) {
      $("status").textContent = `Couldn't start (${s.error}).`;
      $("start").hidden = false;
      return;
    }
    endUrl = `/api/session/${s.sessionId}/end`;
    room = new Room();
    room.on(RoomEvent.TrackSubscribed, (track) => {
      // The avatar publishes one video and one audio track.
      track.attach($(track.kind === Track.Kind.Video ? "avatar" : "voice"));
    });
    room.on(RoomEvent.Disconnected, end);
    await room.connect(s.serverUrl, s.userToken); // join within 60 seconds
    await room.startAudio();
    await room.localParticipant.setMicrophoneEnabled(true);
    $("status").textContent = "Say hello.";
    $("stop").hidden = false;
  };

  $("stop").onclick = end;
  addEventListener("pagehide", end);
</script>
```

## Run it

```bash
node --env-file=.env server.mjs
```

Open `http://localhost:3000`, click **Talk to the avatar**, allow the microphone, and say hello.

Browsers block sound and the microphone until the user does something on the
page, so ask for the microphone only after the click. If the session started
long after the click (Safari), `room.canPlaybackAudio` is `false`: show a "tap
to hear" button that calls `room.startAudio()`. The avatar needs no camera.

For about its first 5 seconds in the room the avatar hears the user as silence, so words spoken
right away are lost. Show "Say hello" once its audio arrives.

## End the session

Your server ends it with `POST /v1/sessions/{id}/end`: the room closes, the avatar leaves, and
billing stops.

```bash
curl -X POST https://api.atmanity.us/v1/sessions/550e8400-e29b-41d4-a716-446655440000/end \
  -H "X-Api-Key: $ATMEE_API_KEY"
```

```json
{
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "completed",
  "endedAt": "2026-11-04T16:42:10Z",
  "alreadyEnded": false,
  "endReason": "ended_by_api"
}
```

* **It's safe to call twice.** Ending a session that already ended answers `200` with
  `alreadyEnded: true`, so retry `503 session_unavailable` (after `Retry-After`) and
  `500 finalize_failed`.
* **It also ends on its own** when the user leaves the room (`user_left`), at
  `actualDurationSeconds` (`max_duration`), or when your credits run out. Ending it yourself still
  stops billing the moment your app is done.
* **To see what it cost,** call `GET /v1/sessions/{id}`: `status`, `endReason`, `billedSeconds` and
  `chargedMicrocredits` (100 microcredits are 1 credit). You're billed only while both the user
  and the avatar were in the room.

## When the start fails

Most errors come back as `{"error": "<code>", "message": "..."}`. Two come as plain text or with no
body: `401` for a wrong or revoked key, and `403` for an avatar the key may not use. A request the API
can't validate (`422`, or `400` for broken JSON) answers `{"code": <number>, "message": "..."}`.
The server above handles all three. Log the message on your server; show the user only that it
didn't work.

| Status | Code                                                                            | What to do                                                                                      |
| ------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `503`  | `no_capacity`                                                                   | No rendering capacity right now. Wait `Retry-After` seconds and start again. Nothing is billed. |
| `409`  | `avatar_busy`                                                                   | The avatar is already in a conversation. End the other one, or use another avatar.              |
| `409`  | `avatar_not_ready`                                                              | An Atmee V2 avatar has no take serving yet. Wait until `readyToChat` is `true`.                 |
| `402`  | `insufficient_credits`, `api_key_spend_cap_reached`, `concurrent_session_limit` | A limit was hit. Don't retry; log which one. The user only needs to hear it's unavailable.      |
| `403`  |                                                                                 | The avatar isn't one this key may use.                                                          |
| `401`  |                                                                                 | The key is wrong or revoked.                                                                    |

Every code is in the [API reference](/docs/v2/api/reference/session/createSession), and the usual
fixes are in [Troubleshooting](/docs/v2/api/troubleshooting).

## What's next

Your app and the avatar can do much more while the session runs: send it state and events, let it
call your app's tools, show it files, or give it an agenda. See
[What your app can do in the room](/docs/v2/api/overview#what-your-app-can-do-in-the-room).

- [Create an avatar](/docs/v2/api/create-avatar): Build one from a portrait and a voice, with JSON or a zip.
- [API reference](/docs/v2/api/reference/session/createSession): Every endpoint, field and error code.
