> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-exo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# File and transcript imports

> Load a ChatGPT or Claude export, a PDF, a DOCX or a meeting transcript, then watch the job until the content is part of the graph.

`POST /v1/import` takes a file, stores it, and hands it to a background worker that parses, chunks, embeds and links it. `POST /v1/import/transcript` does the same for a conversation where you only want one speaker's contributions attributed to the subject.

## Before you start

* A key with the `write` scope. See [Scopes](/scopes).
* The file on disk, or in memory as bytes.
* For a per-end-user product, the subject provisioned first. See [One memory per end user](/guides/subjects).

## The shortest call that works

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/import \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -F "file=@chatgpt-export.zip"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  with open("chatgpt-export.zip", "rb") as fh:
      res = requests.post(
          "https://api.get-exo.com/v1/import",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
          files={"file": fh},
      )
  job_id = res.json()["jobId"]
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import { readFile } from "node:fs/promises";

  const form = new FormData();
  form.append(
    "file",
    new Blob([await readFile("chatgpt-export.zip")]),
    "chatgpt-export.zip",
  );

  const res = await fetch("https://api.get-exo.com/v1/import", {
    method: "POST",
    headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
    body: form,
  });
  const { jobId } = await res.json();
  ```
</CodeGroup>

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{ "jobId": "2b71c9d4-56ea-4f07-8c31-9d4a05e6b718", "status": "pending" }
```

<Warning>
  Do not set `Content-Type` by hand. Every HTTP client above sets `multipart/form-data` with the correct boundary for you, and overriding it produces a body the server cannot parse.
</Warning>

## What you can import

| Kind                    | Extensions                  | Notes                                                                                                         |
| ----------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- |
| ChatGPT export          | `.zip`, `.json`             | The archive OpenAI emails you from **Settings**, **Data controls**, **Export data**. Upload it as it arrives. |
| Claude export           | `.zip`, `.json`             | The export from Anthropic's data controls.                                                                    |
| Documents               | `.pdf`, `.docx`             | Text is extracted and chunked. Scanned images without a text layer are not read.                              |
| Markdown and plain text | `.md`, `.txt`               | Chunked directly.                                                                                             |
| Transcripts             | Text-based transcript files | Use `POST /v1/import/transcript` with `target_speaker`.                                                       |

The contract publishes no maximum file size, so this site does not either. Chunk your own uploads if you have an unusually large archive, and watch the job rather than assuming.

<Note>
  If you are holding a **string** rather than a file, do not write it to a temp file to import it. Use `POST /v1/ingest` instead, which takes JSON. See [Ingesting content](/guides/ingesting).
</Note>

## Importing a transcript

A meeting transcript contains several people. You usually want one person's thinking in the graph, not everyone in the room. `target_speaker` names whose turns are attributed to the subject. The rest of the file still informs the parse, it is just not recorded as the subject's belief.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/import/transcript \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -F "file=@2026-07-14-customer-call.vtt" \
    -F "target_speaker=Dana Ruiz"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  with open("2026-07-14-customer-call.vtt", "rb") as fh:
      res = requests.post(
          "https://api.get-exo.com/v1/import/transcript",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
          files={"file": fh},
          data={"target_speaker": "Dana Ruiz"},
      )
  job_id = res.json()["jobId"]
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import { readFile } from "node:fs/promises";

  const form = new FormData();
  form.append("file", new Blob([await readFile("2026-07-14-customer-call.vtt")]), "call.vtt");
  form.append("target_speaker", "Dana Ruiz");

  const res = await fetch("https://api.get-exo.com/v1/import/transcript", {
    method: "POST",
    headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
    body: form,
  });
  const { jobId } = await res.json();
  ```
</CodeGroup>

`target_speaker` is required on this route. Match the speaker label as it appears in the file.

## Watching the job

Import returns 202 and nothing is in the graph yet. Poll until the job reaches a terminal status.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl https://api.get-exo.com/v1/ingest/2b71c9d4-56ea-4f07-8c31-9d4a05e6b718/status \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, time, requests

  IN_PROGRESS = {"pending", "running"}

  while True:
      res = requests.get(
          f"https://api.get-exo.com/v1/ingest/{job_id}/status",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
      )
      status = res.json()
      print(status["phase"], status["percent"])
      if status["status"] not in IN_PROGRESS:
          break
      time.sleep(3)

  if status["status"] == "failed":
      raise RuntimeError(status["errorMessage"])
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const IN_PROGRESS = new Set(["pending", "running"]);
  let status;

  for (;;) {
    const res = await fetch(
      `https://api.get-exo.com/v1/ingest/${jobId}/status`,
      { headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
    );
    status = await res.json();
    console.log(status.phase, status.percent);
    if (!IN_PROGRESS.has(status.status)) break;
    await new Promise((r) => setTimeout(r, 3000));
  }

  if (status.status === "failed") throw new Error(status.errorMessage);
  ```
</CodeGroup>

<Warning>
  Break on "no longer in progress", not on one spelling of success. This route reports the worker's own status vocabulary, where a finished job is `done`. Only `GET /v1/jobs/{jobId}` renames it to `succeeded`. A loop that waits for `succeeded` here never terminates.
</Warning>

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "jobId": "2b71c9d4-56ea-4f07-8c31-9d4a05e6b718",
  "status": "running",
  "phase": "embedding",
  "completedSteps": ["download", "parse", "chunk"],
  "percent": 62,
  "errorMessage": null
}
```

Polling is the simple path. For anything long running, subscribe to `job.completed` instead of holding a loop open. See [Reacting to change](/guides/reacting-to-change).

## Import history

`GET /v1/import` returns the 20 most recent imports for the effective subject, newest first, as a flat array with no envelope.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl https://api.get-exo.com/v1/import \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  res = requests.get(
      "https://api.get-exo.com/v1/import",
      headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
  )
  for row in res.json():
      print(row["fileName"], row["status"], row["nodesCreated"])
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const res = await fetch("https://api.get-exo.com/v1/import", {
    headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
  });
  for (const row of await res.json()) {
    console.log(row.fileName, row.status, row.nodesCreated);
  }
  ```
</CodeGroup>

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
[
  {
    "importId": "2b71c9d4-56ea-4f07-8c31-9d4a05e6b718",
    "fileName": "chatgpt-export.zip",
    "fileType": "chatgpt_zip",
    "status": "done",
    "nodesCreated": 1284,
    "domainsDetected": 37,
    "createdAt": "2026-07-14T09:04:11Z",
    "completedAt": "2026-07-14T09:19:52Z"
  },
  {
    "importId": "77e0a35c-1b48-4ae2-9f60-3c8d21b04e75",
    "fileName": "2026-07-14-customer-call.vtt",
    "fileType": "markdown",
    "status": "running",
    "nodesCreated": null,
    "domainsDetected": null,
    "createdAt": "2026-07-14T10:22:03Z",
    "completedAt": null
  }
]
```

`nodesCreated` and `domainsDetected` are null until the worker writes its result. `completedAt` is null until the job reaches a terminal status.

<Warning>
  This route reports the **worker's** status vocabulary, the same one `GET /v1/ingest/{jobId}/status` uses: `pending`, `running`, `done`, `failed`. A finished import is `done`, not `succeeded`. Only `GET /v1/jobs/{jobId}` renames it. Filter this list on `done` or your successful imports look like they are still in flight.
</Warning>

A row with `status: "done"` and `nodesCreated: null` should not happen, and is worth a support ticket with the request id.

## After a large import

A big export creates a lot of nodes at once, and the layers built on top of those nodes are not instant. Identity extraction, the manifold, domains, insights and temporal phases are produced by a background cognition cycle that runs after the content lands.

So the sequence a first-time developer actually sees is: import succeeds, `POST /v1/retrieve` starts returning sources, and `GET /v1/recall/identity` is still empty for a while longer. That is expected. See [How a memory is made](/concepts/memory-lifecycle).

## When it goes wrong

<AccordionGroup>
  <Accordion title="The job failed with a parse error" icon="circle-question">
    Corrupt files and embedding failures are terminal on the first attempt rather than retried, because retrying a file that cannot be parsed only burns the queue and delays everything behind it. Fix the file and import again. `errorMessage` on the status response names the reason.
  </Accordion>

  <Accordion title="A PDF imported successfully but retrieved nothing" icon="circle-question">
    The PDF probably has no text layer. Extraction reads embedded text, so a scanned document is a stack of images as far as the parser is concerned, and it produces no chunks. Run it through OCR first and import the result.
  </Accordion>

  <Accordion title="403 permission_denied" icon="circle-question">
    Either the key lacks `write`, or the `X-Exo-Subject` value is not provisioned. Exo refuses an unknown subject selector rather than defaulting to the key owner, so an import can never silently land in the wrong person's partition. See [`permission_denied`](/errors/permission_denied).
  </Accordion>

  <Accordion title="422 validation_error" icon="circle-question">
    Usually a missing `file` part, a hand-set `Content-Type` that broke the multipart boundary, or a missing `target_speaker` on the transcript route. The problem body's `errors[]` names the field. See [`validation_error`](/errors/validation_error).
  </Accordion>

  <Accordion title="I imported the same file twice" icon="circle-question">
    Send an `Idempotency-Key` next time and a retry returns the original job id. If duplicates already landed, forget the extra nodes with `DELETE /v1/graph/nodes/{id}`, which erases their content and tombstones the node. See [Forgetting and portability](/guides/forgetting).
  </Accordion>
</AccordionGroup>

## Next

<Columns cols={2}>
  <Card title="Coding session history" icon="terminal" href="/integrations/sessions">
    A different shape of import: transcripts already on your disk.
  </Card>

  <Card title="Ingesting content" icon="upload" href="/guides/ingesting">
    The JSON routes, for content you already hold as a string.
  </Card>
</Columns>
