# Deep Thought Cloud API reference

Last updated: 2026-10-11, 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 in this reference, and `POST /search` |
| `kb:write` | Everything else: uploads, tags, actions, the meeting notetaker. Implies `kb:read` |
| `graph:read` | The knowledge graph: how people, organisations and topics connect (the [MCP](#mcp) tools `get_entity_neighborhood` and `graph_search`). Not implied by `kb:read` or `kb:write`; asked for when the token is issued |

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`, `.opus`, `.webm`, `.caf`, `.qta` | 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`, `.heic`, `.heif` | Premium |

`.caf` is Apple Core Audio (a voice message from Messages) and `.qta` an Apple Voice Memos
recording. 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`) |
| `since` | A date, `YYYY-MM-DD`: only sources uploaded on or after that day |
| `until` | A date, `YYYY-MM-DD`: only sources uploaded on or before that day |
| `include_deleted` | `true` to include sources in the Trash |
| `has_note` | `true`: only sources with a note; `false`: only sources without one (see [The note on a source](#the-note-on-a-source)) |
| `note_contains` | Text in the note, case-insensitive, at most 200 characters |

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

`since` and `until` are inclusive, count days in Swedish time (Europe/Stockholm) and filter on the
upload time, not on `recording_started_at`; either can be left out. A malformed date, an unknown
`kind` or a malformed `content_source` answers `400`.

**`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 |
| `agent_note` | The note on the source, or `null` (see below) | 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"]}`.

### Exporting a source

**`GET /sources/<source_id>/export`** (Free, `kb:read`). One source as a file to download.

| Parameter | Description |
|---|---|
| `format` | Required: `txt`, `md` (Markdown), `docx` (Word) or `srt` (subtitles) |
| `content` | `transcript` (default), `summary` or `both`. `srt` takes only `transcript` |

The answer is the file itself, with `Content-Disposition: attachment` and the source's title as the
file name (`filename*` carries a title with non-ASCII letters):

| `format` | `Content-Type` |
|---|---|
| `txt` | `text/plain; charset=utf-8` |
| `md` | `text/markdown; charset=utf-8` |
| `docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
| `srt` | `application/x-subrip` |

A file starts with the title, the date, the duration and the speakers by name. The transcript comes
by speaker turn with a `[hh:mm:ss]` time where the source has timings; the summary comes with the
decisions and action items found in the source. `srt` has one numbered cue per stretch of speech,
the speaker's name in front.

Errors: `400` with `invalid_format`, `invalid_content` or `srt_needs_timestamps` (the source has no
timings, for example a pasted text); `404` as for `GET /sources/<source_id>`; `409` with `not_ready`
while the transcript is still being made.

```bash
curl -s -OJ "$API/sources/$KEY/export?format=docx&content=both" -H "Authorization: Bearer $DT_TOKEN"
```

### Where a source was processed

**`GET /sources/<source_id>/processing`** (Free, `kb:read`). Where each processing step of one
source ran: the country, the operator and the host, and whether the step ran on the first choice
or on a fallback. On every plan. The place is stored when the step runs; it is never worked out
afterwards from today's configuration.

```json
{
  "source_id": "DOC_20261011_101500_0367b3dd_82afbf5d",
  "steps": [
    {"step": "transcription",
     "where": {"country": "SE", "operator": "own_hardware", "host": "Stockholm GPU server (card 0)"},
     "first_choice": true, "escalated": false, "model": "...", "at": "2026-10-11T10:16:02+00:00"},
    {"step": "summary",
     "where": {"country": "SE", "operator": "berget", "host": "Berget (Sweden)"},
     "first_choice": true, "escalated": false, "model": "...", "at": "2026-10-11T10:17:40+00:00"}
  ],
  "complete": true,
  "not_recorded": []
}
```

| Field | Contents |
|---|---|
| `source_id` | The source's key |
| `steps` | One entry per step and place, oldest first. Steps an AI model performs are listed once per step, place, model and first choice or fallback, with the latest time. Only attempts that did the work are listed |
| `steps[].step` | `transcription`, `diarisation` (speaker identification), `embedding` (search index), `meeting_recording` (the meeting notetaker), `quick_extraction`, `extraction`, `summary`, `translation`, `chat`, `insights`, `vision` (images and PDF pages), `tagging`, or `analysis` (any other AI step) |
| `steps[].where.country` | Where the step ran: an ISO 3166-1 alpha-2 code, or `EU` |
| `steps[].where.operator` | Who runs the machine: `own_hardware`, `berget`, `openrouter`, `openai`, `anthropic`, `groq` or `recall` |
| `steps[].where.host` | A label for the place, for example `Stockholm GPU server (card 0)`. Never an address |
| `steps[].where.note` | Optional. A sentence the place needs, for example how the meeting notetaker's provider stores and processes the audio |
| `steps[].first_choice` | `true` when the step ran on the first provider it was meant for |
| `steps[].escalated` | `true` when the step moved to a fallback |
| `steps[].model` | The model or engine, or `null` |
| `steps[].at` | When the step ran (ISO-8601, UTC), or `null` |
| `complete` | `false` when a step ran without a record of where |
| `not_recorded` | The steps that ran without a record, by the names above. Every step of a source processed before 11 October 2026 is not recorded, nor are a few paths not yet covered |

The answer is sent with `Cache-Control: no-store`. Errors: `404` as for `GET /sources/<source_id>`
(another account's source is not found); `500` with `processing_unavailable` when the record could
not be read.

```bash
curl -s "$API/sources/$KEY/processing" -H "Authorization: Bearer $DT_TOKEN"
```

### The note on a source

Each source has one free-text note, shared by everyone in the account. Assistants and systems use
it to record what they have done with a source, so the next run can skip it:

```text
processed by core-skills transcript 2026-10-10 → vault note meetings/261010-weekly.md
```

The note is `agent_note` on `GET /sources/<source_id>` and on every row of `GET /sources`, or
`null` when there is none:

```json
{"text": "processed by …", "updated_at": "2026-10-10T12:34:56Z",
 "updated_by_user": "anna", "updated_by_client": "mcp"}
```

`updated_by_client` is `mcp`, `api`, `web`, `ios`, `android`, `trillian` or `other`.

**`GET /sources/<source_id>/note`** (`kb:read`). `{"source_id": "...", "agent_note": {...} | null,
"max_length": 2000}`.

**`PUT /sources/<source_id>/note`** (`kb:write`). Body `{"text": "..."}`. Replaces the note; an
empty text clears it. At most 2000 characters; control characters are removed. Answers
`{"status": "updated" | "cleared" | "unchanged", "source_id": "...", "agent_note": {...} | null}`.
`400 invalid_text`, `400 note_too_long` (with `max_length`), `404 not_found`,
`429 rate_limited` (60 writes a minute per user; `retry_after` in seconds).

```bash
curl -s -X PUT "$API/sources/$SOURCE/note" -H "Authorization: Bearer $DT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "processed by my-tool 2026-10-10 → ticket 4711"}'
curl -s "$API/sources?has_note=false&limit=20" -H "Authorization: Bearer $DT_TOKEN"
```

Writing the note changes nothing else: the source is not reprocessed and its status, dates and
e-mail stay as they are. The last ten writes are kept on the server. Not to be confused with
`POST /sources/<source_id>/notes`, which appends to a separate list.

### 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, and with or without a note (`has_note`, `note_contains`) |
| `get_source_extraction` | `kb:read` | One source, structured, no transcript. `fields`: `artifacts` (the default: its decisions, actions with who owns them, quotes, open questions, who spoke, title and date), `entities` (who and what it mentions) or `full` (everything, including what it is) |
| `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, 50 at a time (`limit`, `offset`), optionally those whose name contains a word (`query`) |
| `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, people and projects (`scope_type`, e.g. `project`) |
| `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 |
| `set_source_note` | `kb:write` | Write the note on a source, e.g. "processed by <tool> <date> → <where>". Shown as `agent_note` by `list_sources`, `get_source_extraction` and `get_source_transcript` |

`create_action` and `set_source_note` are the two tools that write; both need a connection
approved with `kb:write`.

An answer over 25,000 characters is cut between two items (never inside one) and says so:
`truncated: true`, `returned`, `total` and a `cursor`; the same tool called with `cursor` returns the
next part. A cursor lasts 15 minutes. A long text is cut at a line break; a single item that is larger
than 25,000 characters on its own arrives whole, in a part of its own.

## 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` |
| `GET` | `/sources/<source_id>/export` | Free | `kb:read` |
| `GET` | `/sources/<source_id>/processing` | Free | `kb:read` |
| `POST`, `DELETE` | `/sources/<source_id>/tags` | Free | `kb:write` |
| `GET` | `/sources/<source_id>/note` | Free | `kb:read` |
| `PUT` | `/sources/<source_id>/note` | Free (through MCP: Premium) | `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.
