# Deep Thought Cloud API reference

Last updated: 2026-10-08, server 2.37.x. Overview: <https://deep-thought.cloud/en/api/>.
This file: <https://deep-thought.cloud/api/reference.md> (Markdown, for people and for AI agents).

Deep Thought Cloud turns recordings and documents into a searchable knowledge base: transcripts,
speakers, summaries, people and organisations, actions and insights. This reference covers what
an integration needs: authentication, uploading, waiting for processing, and reading the results.
It is a curated subset of the API; everything not listed here is internal or may change without
notice.

## Contents

1. [Basics](#basics)
2. [Authentication](#authentication)
3. [Plans](#plans)
4. [Rate limits](#rate-limits)
5. [Errors](#errors)
6. [Quick start](#quick-start)
7. [Uploading](#uploading)
8. [Processing status](#processing-status)
9. [Reading](#reading)
10. [Meeting notetaker](#meeting-notetaker)
11. [MCP](#mcp)
12. [Endpoint summary](#endpoint-summary)

## Basics

| | |
|---|---|
| Base URL | `https://api.deep-thought.cloud/api/v1` |
| Format | JSON in and out, except uploads (`multipart/form-data`) |
| Times | ISO-8601, UTC |
| Transport | HTTPS only |

Every path below is relative to the base URL. A source (a recording, document or text) is
identified by its key, called `source_id` or `cache_key` in responses (for example
`DOC_20261001_044444_0367b3dd_82afbf5d`).

## Authentication

Send a token on every request:

```
Authorization: Bearer <token>
```

### Device token (use this today)

A device token starts with `dtd_`. Deep Thought issues it for your account; it is shown once, so
store it as a secret. It does not expire by default and can be revoked at any time in the Web App
under Settings, Devices. Revocation takes effect on the next request.

A device token carries scopes:

| Scope | Allows |
|---|---|
| `kb:read` | Every `GET` request, and `POST /search` |
| `kb:write` | Everything else: uploads, tags, actions, the meeting notetaker. Implies `kb:read` |

A request outside the token's scopes answers `403 {"error": "insufficient_scope", "required": ["kb:write"], ...}`.
A token with only `kb:read` cannot upload and cannot use `POST /chat`.

### API keys (Enterprise, coming)

API keys are built for server integrations and will replace device tokens for them: one key per
system, scoped, revocable, created by an account owner or admin in the Web App. A key looks like
`dtk_<key_id>_<secret>` and is sent the same way (`Authorization: Bearer dtk_...`). They are not
switched on yet and no date is set. When they are, moving from a device token to a key changes
only the token you send.

### Passwords from scripts are being retired

Signing in with e-mail and password (`POST /auth/login`) and HTTP Basic are for the Deep Thought
apps. Do not build an integration on them. Using them from a script is being retired for every
plan: the planned dates are a notice on 2026-10-16 and the end on 2026-11-16. From the end date a
script gets `401 {"error": "basic_auth_retired"}` or `401 {"error": "password_login_app_only"}`.
Until then such responses may carry `Deprecation` and `Sunset` headers.

## Plans

Each feature belongs to a plan: Free, Premium or Enterprise. The endpoints below are marked with
the lowest plan that includes them. A request for a feature your plan does not include answers
`402` with `{"error": "plan_required", "plan": "premium", "feature": "<feature>"}`. The current
plan contents are published, without authentication, at `GET /public/plans` and
`GET /public/features`.

| Plan | Includes (API view) |
|---|---|
| Free | Audio and video upload, transcripts, speakers, tags, listing and reading your sources |
| Premium | Free, plus text, document and image upload, summaries and structured extraction, people and organisations, actions, insights and verticals, semantic search and chat, MCP, the meeting notetaker |
| Enterprise | Premium, plus API keys for your own systems (when switched on), integrations, multi-user accounts |

## Rate limits

Limits are counted per client IP address and per endpoint, in fixed windows:

| Endpoints | Limit |
|---|---|
| Authenticated requests, `/sources/upload` included | 50 per second |
| Sign-in requests (`/auth/*`) | 5 per minute |

Every response carries the current state:

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | The limit for this endpoint in the current window |
| `X-RateLimit-Remaining` | Requests left in the window |
| `X-RateLimit-Reset` | When the window resets, in Unix epoch seconds |
| `Retry-After` | Seconds to wait before retrying |

Over the limit the answer is `429` with a `Retry-After` header and
`{"error": "rate_limit_exceeded", "retry_after": <seconds>, "limit": "50 per 1 second"}`.
Wait at least `retry_after` seconds before the next request.

## Errors

An error answers with an HTTP status and a JSON body with an `error` field: a short code
(`plan_required`) or a sentence. Many add `message`. Read the status code first.

| Status | Meaning | What to do |
|---|---|---|
| `400` | The request is malformed or a required field is missing | Fix the request |
| `401` | No token, an unknown or revoked token | Check the `Authorization` header |
| `402` | `plan_required`: the feature is not in your plan | See [Plans](#plans) |
| `403` | `insufficient_scope` (token scopes), or `not_allowed` (owner or admin only) | Use a token with the scope |
| `404` | Not found, or not yours. For a source key, see [the key can change](#the-key-can-change) | |
| `409` | Conflict, for example a duplicate | |
| `413` | The file is larger than the upload limit (250 MB). Refused before it reaches the API, so the body may not be JSON | Split or compress the file |
| `415` | The file type is not accepted, or its content does not match its extension (`file_validation_failed`) | |
| `429` | `rate_limit_exceeded` | Wait `Retry-After` seconds |
| `5xx` | A server-side problem. `503` may carry `Retry-After` | Retry with backoff |

## Quick start

Upload a recording, wait for the transcript, fetch it. Set `DT_TOKEN` to your token first.

```bash
export DT_TOKEN="dtd_..."          # your token; never commit it
API=https://api.deep-thought.cloud/api/v1

# 1. Upload. A new file answers 202 with a task_id.
TASK=$(curl -s -X POST "$API/sources/upload" \
  -H "Authorization: Bearer $DT_TOKEN" \
  -F "file=@meeting.m4a" -F "language=sv" -F "tags=customer-x" \
  -F "content_source=meeting" \
  -F 'content_source_metadata={"meeting_started_at":"2026-10-08T09:00:00Z"}' \
  | jq -r '.task_id // empty')

# 2. Wait for the source key, then for the transcript.
until KEY=$(curl -s "$API/sources/tasks/$TASK" -H "Authorization: Bearer $DT_TOKEN" \
    | jq -r '.result.cache_key // .cache_key // empty') && [ -n "$KEY" ]; do sleep 10; done

until [ "$(curl -s "$API/sources/$KEY/status" -H "Authorization: Bearer $DT_TOKEN" \
    | jq -r '.status.transcription.status')" = "completed" ]; do sleep 30; done

# 3. Fetch the source: transcript, title, summary and extraction.
curl -s "$API/sources/$KEY" -H "Authorization: Bearer $DT_TOKEN" \
  | jq '.result | {title: .llm_title, text: (.diarization_text_named // .diarization_text // .text)}'
```

If the upload answers `{"status": "duplicate_upload", "cache_key": ...}` instead, the account
already has this file and `cache_key` is its key; skip to step 2's second loop.

## Uploading

### `POST /sources/upload`

`multipart/form-data`. Scope `kb:write`.

| Field | Required | Description |
|---|---|---|
| `file` | Yes | The file. At most 250 MB |
| `language` | No | Language hint, for example `sv` or `en`. Default: detected |
| `tags` | No | Comma-separated tags, for example `customer-x,q4` |
| `content_source` | No | What the source is: `meeting`, `voice_memo`, `interview`, `phone_call`, `lecture`, `presentation`, `podcast` or `email`. Any other value is ignored |
| `content_source_metadata` | No | A JSON object, at most 16 KB, stored with the source. Malformed JSON or a larger value is ignored, never refused |

**File types and plans**

| Kind | Extensions | Plan |
|---|---|---|
| Audio | `.mp3`, `.m4a`, `.wav`, `.aac`, `.flac`, `.ogg`, `.webm`, `.caf` | Free |
| Video (the audio is transcribed) | `.mp4`, `.m4v`, `.mov`, `.mkv`, `.avi`, `.webm` | Free |
| Text | `.md`, `.markdown`, `.txt`, `.vtt` | Premium |
| Documents | `.pdf`, `.docx`, `.doc` | Premium |
| Images (text is read from the image) | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp` | Premium |

A text file that contains code (a pasted snippet in meeting notes, for example) is still accepted
as text. Below Premium a text, document or image upload answers `402` with
`"feature": "non_audio_sources"`.

**Dating a source.** By default a source is dated by its own metadata (an embedded recording
time, or a date in the file name), else by the upload time. To give the date yourself, for
example when you upload a meeting after the fact, send:

```
content_source=meeting
content_source_metadata={"meeting_started_at": "2026-10-08T09:00:00Z"}
```

`meeting_started_at` is ISO-8601 in UTC, with the `Z`; a value without an offset is read as UTC.
This works for audio, video, text and documents. The source's date is then returned as
`recording_started_at`, and `recording_time_source` says which rule set it (treat an unknown
value as "the upload date"). The source list is still ordered by upload time.

**The size limit.** `GET /info` reports the limit that is enforced: `limits.max_file_size` in
bytes (`262144000`) and `limits.max_file_size_mb` (`"250MB"`). `GET /auth/user-limits` reports the
same number for your account. A larger file answers `413`.

**Response.** A new file answers `202`:

```json
{"status": "queued", "task_id": "abc123-def456", "queue_position": 3,
 "estimated_wait_time": 300, "filename": "meeting.m4a"}
```

It carries no source key yet; get it from `GET /sources/tasks/<task_id>`. A file the account
already holds answers `{"status": "duplicate_upload", "cache_key": "...", "transcription_status": "...", "title": "..."}`.

`POST /sources` is the same endpoint under a second path.

## Processing status

### `GET /sources/tasks/<task_id>`

The upload task. `status` is `pending`, `processing`, `completed` (with `result`, which carries
`cache_key`) or `failed` (with `error`). Poll it until you have the source key.

### `GET /sources/<source_id>/status`

Where each processing stage of a source stands:

```json
{"success": true, "cache_key": "DOC_...",
 "status": {
   "transcription":  {"status": "completed"},
   "llm_extraction": {"status": "completed"},
   "aci":            {"status": "pending", "processed": false},
   "diarization":    {"status": "completed", "speaker_count": 3},
   "semantic_index": {"status": "pending", "indexed": false}}}
```

Each stage is `pending`, `processing` or `completed`.
The transcript is ready when `transcription.status` is `completed`; `aci` is the structured
extraction (Premium) and finishes later. The Free plan never runs `aci`, so do not wait for it there.

**Polling advice.** Poll every 15 to 30 seconds, not faster. Processing time grows with the
recording's length and the queue; for a long recording, wait minutes rather than seconds before
the first status call. Respect `Retry-After` on a `429`. There is no push notification of completion
today: poll.

### The key can change

When transcription finishes, a source can be stored under a new `DOC_...` key and the upload's
key then answers `404`. Use the key from the task result (`result.cache_key`), and on a `404`
for a source you just uploaded, upload the same file again: it answers `duplicate_upload` with
the current key, without processing it twice.

## Reading

All reads need `kb:read`.

### Sources

**`GET /sources`** (Free). Your sources, newest upload first.

| Parameter | Description |
|---|---|
| `limit` | Default 50, at most 100 |
| `before` | Paging: pass the previous page's `next_cursor` to get older items |
| `after` | A cursor: only items newer than it (for refreshing) |
| `tag` | One tag. `_untagged` returns sources without tags |
| `kind` | `recording`, `video`, `document`, `email`, `podcast`, `integration` or `other` |
| `content_source` | For example `meeting` or `voice_memo` |
| `client` | The uploading client, for example `api` or `trillian` |
| `person` | A speaker's `person_id` (from `GET /speakers/persons`) |
| `include_deleted` | `true` to include sources in the Trash |

The response is `{"files": [...], "count": n, "has_more": bool, "next_cursor": "..."}`. Page by
passing `next_cursor` as `before` until `has_more` is `false`.

**`GET /sources/<source_id>`** (Free). One source in full, under `result`: the transcript and
everything extracted from it.

| Field | Contents | Plan |
|---|---|---|
| `text` | The plain transcript | Free |
| `diarization_text` | The transcript with speaker labels (`SPEAKER_00`, ...) | Free |
| `diarization_text_named` | The transcript with speaker names, where they are known | Free |
| `diarization_speakers_named` | Label to name map | Free |
| `original_filename`, `language`, `duration`, `tags`, `content_source`, `recording_started_at` | Metadata | Free |
| `llm_title`, `llm_keywords` | Title and keywords | Free |
| `llm_summary` | Summary | Premium |
| `people`, `organizations`, `locations` | Who and what is mentioned | Premium |
| `actions` | Actions and decisions found in the source | Premium |

For "who said what", read `diarization_text_named`, then `diarization_text`, then `text`.

**`GET /sources/<source_id>/info`** (Free). Metadata only, without the transcript; cheaper for
lists.

**`POST /sources/<source_id>/tags`**, **`DELETE /sources/<source_id>/tags`** (Free, `kb:write`).
Body `{"tags": ["customer-x"]}`.

### Search and chat

**`POST /search`** (Premium). Semantic search across your sources. Allowed with `kb:read`.

```json
{"query": "what was decided about the launch date", "n_results": 5, "tag_filter": ["customer-x"]}
```

`n_results` is at most 20. Each result carries `transcription_id` (the source key), `title`,
`text` (the matching passage) and `score`.

**`POST /chat`** (Premium, `kb:write`). A question answered from your sources, with the sources it
used. Body `{"message": "...", "tag_scope": "customer-x", "conversation_id": "..."}`;
`tag_scope` and `conversation_id` are optional (send the returned `conversation_id` back to
continue a conversation). The answer is `message.content`, the sources `message.sources`.

### Tags and speakers

| Endpoint | Plan | Returns |
|---|---|---|
| `GET /tags` | Free | The tags used on your sources |
| `GET /tags/definitions` | Free | Your tag definitions, with usage counts |
| `GET /speakers/persons` | Free | The people your account knows by voice: `person_id`, `display_name`, aliases, `doc_count` |
| `GET /transcriptions/<source_id>/speakers` | Free | The speakers of one source and suggested names |

### People, organisations and topics

| Endpoint | Plan | Returns |
|---|---|---|
| `GET /entities` | Premium | People, organisations, projects and topics across your sources. Filters `type`, `tag` |
| `GET /entities/<entity_id>` | Premium | One of them: name, aliases, type, how often it appears |
| `GET /entities/<entity_id>/related` | Premium | What it is connected to, and the sources it appears in |

### Insights and verticals

| Endpoint | Plan | Returns |
|---|---|---|
| `GET /insights` | Premium | Insights: how a tag's content has developed over time. Filters `tag_slug`, `status` |
| `GET /insights/<insight_id>` | Premium | One insight |
| `GET /contexts` | Premium | Your verticals: the running picture of each tag, feed and person |
| `GET /contexts/<scope_type>` | Premium | The verticals of one type: `tag`, `feed` or `person` |
| `GET /contexts/<scope_type>/<scope_key>` | Premium | One vertical in full, with its summary |

### Actions

| Endpoint | Plan | Does |
|---|---|---|
| `GET /actions` | Premium | Your actions. Filters `status` (`pending`, `completed`, `cancelled`), `priority`, `limit` (at most 500) |
| `GET /actions/<action_id>` | Premium | One action |
| `POST /actions/create` | Premium, `kb:write` | Create an action. Body `{"description": "...", "priority": "high", "due_date": "2026-10-15", "assigned_to": "..."}`; only `description` is required |
| `PUT /actions/<action_id>` | Premium, `kb:write` | Update one, for example `{"status": "completed"}` |

Below Premium, `GET /actions` still returns existing actions with
`{"plan": {"available": false}}`, and creating one is refused.

### Account

| Endpoint | Plan | Returns |
|---|---|---|
| `GET /info` | Free | The API, your account, and the upload limit in force |
| `GET /auth/user-limits` | Free | The largest upload your account can make |
| `GET /public/plans`, `GET /public/features` | No token needed | What each plan includes |

## Meeting notetaker

Premium and Enterprise. The notetaker joins a Zoom, Google Meet or Teams meeting, records it, and
the recording becomes a source like any upload. Connecting a calendar is done in the Web App
(Settings); the API reads the calendars and the meetings.

**`POST /recall/bots`** (`kb:write`). Send the notetaker to a meeting now.

```bash
curl -s -X POST "$API/recall/bots" -H "Authorization: Bearer $DT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"meeting_url": "https://meet.google.com/abc-defg-hij"}'
```

`meeting_url` is the full meeting link. Answers `201 {"ok": true, "bot": {...}}`; the notetaker
joins within seconds. `400 meeting_url_required`, `402 plan_required` (or `cap_reached` when the
month's meeting hours are used), `502 create_bot_failed`.

| Endpoint | Returns |
|---|---|
| `GET /recall/bots` | Your notetakers and their status. `?limit=` at most 200. A finished one names its recording (`cache_key`) |
| `GET /recall/bots/<bot_id>` | One notetaker |
| `DELETE /recall/bots/<bot_id>` | Removes one that has not joined yet, or takes it out of the meeting. The recording is kept |
| `GET /recall/upcoming` | Upcoming calendar meetings and whether the notetaker will join each: `decision`, `decision_reason`. `?days=` (default 14, at most 28). Read-only for integrations |
| `GET /recall/calendars` | Your connected calendars: `provider`, `status` (`active`, or `disconnected`, which needs reconnecting in the Web App) |

## MCP

AI assistants (Claude and other MCP clients) connect to the same knowledge base through MCP.

| | |
|---|---|
| Server | `https://mcp.deep-thought.cloud/mcp` (Streamable HTTP) |
| Authentication | OAuth 2.1 with dynamic client registration: the client opens a browser, you sign in and approve. The client then holds a token revocable in the Web App under Settings, Devices |
| Scopes | `kb:read` (default), `kb:write` (opt in), `graph:read` (opt in) |
| Plan | Premium |

| Tool | Scope | Does |
|---|---|---|
| `ping` | none | Check the connection and that the token is valid |
| `ask` | `kb:read` | A question in plain language, answered from your sources, with citations |
| `search` | `kb:read` | Semantic search across your sources, optionally narrowed by tag |
| `list_sources` | `kb:read` | Your sources, most recent first, optionally by tag |
| `get_source_extraction` | `kb:read` | One source, structured: what it is, who and what it mentions, its decisions, actions, quotes and open questions. No transcript |
| `get_source_transcript` | `kb:read` | One source's full transcript, with speaker names where they are known |
| `list_speakers` | `kb:read` | The people your account knows by voice |
| `list_tags` | `kb:read` | The tags you have set on your sources |
| `list_entities` | `kb:read` | The people, organisations, projects and topics across your sources |
| `get_entity` | `kb:read` | One of them: name, aliases, type and how often it appears |
| `get_entity_related` | `kb:read` | What it is connected to, and the sources it appears in |
| `get_entity_neighborhood` | `graph:read` | Its connections one to three steps out |
| `graph_search` | `graph:read` | Find people, organisations and topics by name |
| `list_verticals` | `kb:read` | The verticals that have grown from your tags, feeds and people |
| `get_vertical_summary` | `kb:read` | One vertical's current picture across all its sources |
| `list_insights` | `kb:read` | Insights: how a tag's content has developed, in time order |
| `get_insight` | `kb:read` | One insight |
| `create_action` | `kb:write` | Create an action item. The only tool that writes |

## Endpoint summary

| Method | Path | Plan | Scope |
|---|---|---|---|
| `POST` | `/sources/upload` (also `/sources`) | Free (audio, video); Premium (text, documents, images) | `kb:write` |
| `GET` | `/sources/tasks/<task_id>` | Free | `kb:read` |
| `GET` | `/sources/<source_id>/status` | Free | `kb:read` |
| `GET` | `/sources` | Free | `kb:read` |
| `GET` | `/sources/<source_id>` | Free (extraction fields Premium) | `kb:read` |
| `GET` | `/sources/<source_id>/info` | Free | `kb:read` |
| `POST`, `DELETE` | `/sources/<source_id>/tags` | Free | `kb:write` |
| `POST` | `/search` | Premium | `kb:read` |
| `POST` | `/chat` | Premium | `kb:write` |
| `GET` | `/tags`, `/tags/definitions` | Free | `kb:read` |
| `GET` | `/speakers/persons` | Free | `kb:read` |
| `GET` | `/transcriptions/<source_id>/speakers` | Free | `kb:read` |
| `GET` | `/entities`, `/entities/<entity_id>`, `/entities/<entity_id>/related` | Premium | `kb:read` |
| `GET` | `/insights`, `/insights/<insight_id>` | Premium | `kb:read` |
| `GET` | `/contexts`, `/contexts/<scope_type>`, `/contexts/<scope_type>/<scope_key>` | Premium | `kb:read` |
| `GET` | `/actions`, `/actions/<action_id>` | Premium | `kb:read` |
| `POST` | `/actions/create` | Premium | `kb:write` |
| `PUT` | `/actions/<action_id>` | Premium | `kb:write` |
| `POST` | `/recall/bots` | Premium | `kb:write` |
| `GET` | `/recall/bots`, `/recall/bots/<bot_id>` | Premium | `kb:read` |
| `DELETE` | `/recall/bots/<bot_id>` | Premium | `kb:write` |
| `GET` | `/recall/upcoming`, `/recall/calendars` | Premium | `kb:read` |
| `GET` | `/info`, `/auth/user-limits` | Free | `kb:read` |
| `GET` | `/public/plans`, `/public/features` | None (no token) | none |

Questions: support@deep-thought.cloud.
