# Tonetuned API

Tonetuned builds FAQ assistants ("agents") that answer a business's customers in the owner's voice and never say anything the owner did not approve.
Each agent has knowledge items (FAQs, facts, documents), a persona (tone sliders and hard rules) and a public chat link and website widget.
This API lets you do everything the owner's dashboard does: manage agents, knowledge and persona, import files and web pages with a reviewed disclosure policy, test answers, and read conversations, leads and unanswered questions.

Human-readable copy of this file: https://tonetuned.rjscode.com/docs/api

## Authentication

Every request needs the owner's API key in a header:

```
Authorization: Bearer tt_live_YOUR_KEY
```

The owner creates keys at https://tonetuned.rjscode.com/app/developers (the full key is shown once). Keys start with `tt_live_`. A key has full access to the owner's account, so never print it, log it or put it in a URL. If a key leaks, the owner revokes it on the same page.

In the examples below, set the key once:

```
export TONETUNED_KEY=tt_live_YOUR_KEY
```

## Base URL

```
https://tonetuned.rjscode.com/api/v1
```

JSON in, JSON out. Send `Content-Type: application/json` with a body. Ids are UUIDs.

## Quick start for agents

1. `GET /agents` to find the agent (or `POST /agents` to create one).
2. Add knowledge: for the owner's files or website, run an import (see "Imports and the disclosure policy"); for a few FAQs you write yourself, `POST /agents/{id}/knowledge`.
3. `PATCH /agents/{id}` to tune the voice.
4. `POST /agents/{id}/preview` with a few real customer questions, and fix gaps until the answers are right.
5. Give the owner the agent's `chat_url` or `embed_snippet`. Platform-by-platform install steps (WordPress, Shopify, Wix, Next.js and more): https://tonetuned.rjscode.com/docs/install?agent=PUBLIC_ID

## Visibility levels (read this before adding knowledge)

Every knowledge item has a visibility. It is enforced by the database, not by asking the model:

- **public**: any visitor can get this answer.
- **customer**: only used when the agent's disclosure slider is 50 or higher and a visitor asks about that topic directly (for example prices of extras). Never volunteered.
- **internal**: never sent to the model for visitors. Only the owner sees it, in preview with `staffView: true`. Use it for notes like door codes, private phone numbers, supplier margins.

When unsure, choose internal. Ask the owner before making anything public that looks private.

## Account

### GET /me

Plan, usage (this month; on Free the total of all months, with lifetime true), quota, agent limit, business name and unpaid balance (Free-plan grace period, in euro cents).

```
curl https://tonetuned.rjscode.com/api/v1/me -H "Authorization: Bearer $TONETUNED_KEY"
```

```
{
  "plan": "free",
  "business_name": "Lakeside Apartments",
  "usage": { "month": "2026-10-01", "messages": 12, "quota": 100, "lifetime": true },
  "agents": { "used": 1, "limit": 1 },
  "balance_cents": 0,
  "grace_started_at": null
}
```

### PATCH /me

Set the business name the agents introduce themselves with ("the AI assistant for ...").

```
curl -X PATCH https://tonetuned.rjscode.com/api/v1/me -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" -d '{"business_name":"Lakeside Apartments"}'
```

Returns the same shape as `GET /me`.

## Agents

### GET /agents

```
curl https://tonetuned.rjscode.com/api/v1/agents -H "Authorization: Bearer $TONETUNED_KEY"
```

```
{
  "agents": [
    {
      "id": "6f1c2a9e-3b7d-4c55-9a51-0d1e2f3a4b5c",
      "name": "Ada",
      "public_id": "k3x9q2m7ab",
      "is_example": false,
      "created_at": "2026-10-27T09:12:00Z",
      "updated_at": "2026-10-27T09:12:00Z",
      "chat_url": "https://tonetuned.rjscode.com/a/k3x9q2m7ab",
      "embed_snippet": "<script src=\"https://tonetuned.rjscode.com/embed.js\" data-agent=\"k3x9q2m7ab\" async></script>"
    }
  ]
}
```

Every account starts with one example agent (`is_example: true`). It is read-only and preview-only: you can read it and preview it, but not change or delete it, and it has no public chat link. It does not count toward the plan's agent limit.

### POST /agents

Body: `name` (required, up to 80 characters), `preset` (optional: `friendly`, `professional`, `concise`, `closer`, `insider`; default `friendly`). Returns 201 and the agent with its full persona. Fails with 403 `plan_limit` when the plan's agent limit is reached.

```
curl -X POST https://tonetuned.rjscode.com/api/v1/agents -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" -d '{"name":"Ada","preset":"professional"}'
```

### GET /agents/{id}

The agent plus its persona:

```
{
  "id": "6f1c2a9e-3b7d-4c55-9a51-0d1e2f3a4b5c",
  "name": "Ada",
  "...": "same fields as in the list",
  "review_mode": false,
  "persona": {
    "name": "Ada",
    "greeting": "Hi! I'm Ada, an AI assistant. How can I help?",
    "sliders": { "friendliness": 70, "detail": 50, "disclosure": 30, "salesPush": 30, "handoff": 40 },
    "hardRules": ["promise discounts"],
    "handoffText": "I'm not sure about that. Leave your email and a person will get back to you.",
    "preset": "friendly",
    "accent": "#2d5b46",
    "styleGuide": [],
    "guidelines": []
  }
}
```

### PATCH /agents/{id}

Body (all optional, at least one): `name`, `persona` (a partial patch merged into the current persona), `apply_preset` (reset the persona to a preset first, then apply the patch).

Persona fields and limits:

- `name`: assistant display name, 1-60 characters.
- `greeting`: first message, up to 300 characters.
- `sliders`: each 0-100, send only the ones you change. `friendliness` (formal and brief .. warm and chatty), `detail` (one or two sentences .. full walkthrough), `disclosure` (below 50 = public knowledge only, 50+ = public and customer), `salesPush` (never pitches .. suggests next steps and asks for an email), `handoff` (tries to answer .. hands off to a person early).
- `hardRules`: up to 20 lines of things the agent must never do, each up to 200 characters, written without "Never" (for example "promise discounts"). This replaces the whole list.
- `handoffText`: what the agent says when it hands off to a person, up to 300 characters.
- `accent`: widget colour as `#rrggbb`.
- `styleGuide`, `guidelines`: up to 8 lines each (how the owner writes; how to handle tricky cases).

```
curl -X PATCH https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" \
  -d '{"persona":{"sliders":{"detail":30},"hardRules":["promise discounts","give medical advice"]}}'
```

Returns the updated agent (same shape as `GET /agents/{id}`).

### DELETE /agents/{id}

Deletes the agent with all its knowledge, conversations and leads. Cannot be undone; confirm with the owner first.

```
curl -X DELETE https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID -H "Authorization: Bearer $TONETUNED_KEY"
```

```
{ "deleted": true, "id": "6f1c2a9e-3b7d-4c55-9a51-0d1e2f3a4b5c" }
```

## Knowledge

Kinds: `faq` (title = question, content = answer), `fact` (title = label, content = value, for example "Check-in time" / "from 3 pm"), `doc` (longer text; title optional; split into passages). Items imported from a web page have kind `page` (read and edit only).
Limits: title up to 300 characters, content up to 200,000 characters. Short, specific FAQs and facts answer best.

### GET /agents/{id}/knowledge

Query: `limit` (1-200, default 50), `offset`, `kind`, `visibility`. Content longer than 2,000 characters is cut (`content_truncated: true`); fetch the item for the full text. `next_offset` is null on the last page.

```
curl "https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/knowledge?limit=50" -H "Authorization: Bearer $TONETUNED_KEY"
```

```
{
  "items": [
    {
      "id": "0b6d8f6e-7c1a-4d3e-8f2b-1a2b3c4d5e6f",
      "kind": "faq",
      "title": "Can I bring my dog?",
      "content": "Dogs are welcome on request. Tell us before you book.",
      "content_length": 54,
      "content_truncated": false,
      "visibility": "public",
      "source_url": null,
      "created_at": "2026-10-27T09:15:00Z",
      "updated_at": "2026-10-27T09:15:00Z"
    }
  ],
  "next_offset": null
}
```

### GET /agents/{id}/knowledge/{itemId}

One item with its full content (same item shape).

### POST /agents/{id}/knowledge

Add one item, or up to 100 in one request with `{"items": [...]}` (at most 500,000 content characters per request). `visibility` defaults to `public`. Items are live right away. Returns 201.

```
curl -X POST https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/knowledge -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" -d '{
  "items": [
    {"kind":"faq","title":"Can I bring my dog?","content":"Dogs are welcome on request. Tell us before you book.","visibility":"public"},
    {"kind":"fact","title":"Check-in","content":"from 3 pm, self check-in with a key box","visibility":"public"},
    {"kind":"fact","title":"Key box code","content":"4711","visibility":"internal"}
  ]
}'
```

```
{
  "created": [
    { "id": "0b6d8f6e-7c1a-4d3e-8f2b-1a2b3c4d5e6f", "kind": "faq", "title": "Can I bring my dog?", "visibility": "public" },
    { "id": "...", "kind": "fact", "title": "Check-in", "visibility": "public" },
    { "id": "...", "kind": "fact", "title": "Key box code", "visibility": "internal" }
  ]
}
```

### PATCH /agents/{id}/knowledge/{itemId}

Body: any of `title`, `content`, `visibility`. Returns the full item.

```
curl -X PATCH https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/knowledge/$ITEM_ID -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" -d '{"visibility":"customer"}'
```

### DELETE /agents/{id}/knowledge/{itemId}

```
curl -X DELETE https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/knowledge/$ITEM_ID -H "Authorization: Bearer $TONETUNED_KEY"
```

```
{ "deleted": true, "id": "0b6d8f6e-7c1a-4d3e-8f2b-1a2b3c4d5e6f" }
```

## Preview (test answers)

### POST /agents/{id}/preview

Ask the agent a question exactly as a visitor would. Body: `message` (required, up to 2,000 characters), `history` (optional earlier turns, `[{"role":"user"|"assistant","content":"..."}]`, last 8 used), `staffView` (optional, true also uses internal items). Nothing is saved and it does not use the monthly quota. Limit: 60 previews per hour and 300 per day per account (shared with the dashboard).

```
curl -X POST https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/preview -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" -d '{"message":"Can I bring my dog?"}'
```

```
{
  "answer": "Yes, dogs are welcome on request. Please tell us before you book.",
  "confidence": "high",
  "handoff": false,
  "askEmail": false,
  "sources": [
    { "id": "c1d2e3f4-...", "title": "Can I bring my dog?", "visibility": "public", "snippet": "Q: Can I bring my dog?\nA: Dogs are welcome on request..." }
  ]
}
```

`confidence`: `high` = the knowledge clearly answers; `low` = partly; `none` = it does not, and the agent hands off. A `none` answer means knowledge is missing: add an item and preview again.

## Conversations, leads, unanswered questions

### GET /agents/{id}/conversations

Most recent first, each with its messages oldest first (up to 100). Query: `limit` (1-50, default 20), `offset`, `needs_human` (true/false).

```
curl "https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/conversations?limit=20" -H "Authorization: Bearer $TONETUNED_KEY"
```

```
{
  "conversations": [
    {
      "id": "9e8d7c6b-...",
      "started_at": "2026-10-27T10:00:00Z",
      "last_at": "2026-10-27T10:02:00Z",
      "needs_human": false,
      "is_lead": true,
      "lead_email": "guest@example.com",
      "review_status": "pending",
      "messages": [
        { "id": "...", "role": "visitor", "content": "Do you have parking?", "confidence": null, "created_at": "2026-10-27T10:00:00Z" },
        { "id": "...", "role": "assistant", "content": "Yes, free private parking next to the house.", "confidence": "high", "created_at": "2026-10-27T10:00:02Z" }
      ]
    }
  ],
  "next_offset": null
}
```

### GET /agents/{id}/leads

Conversations where the visitor left an email or showed buying intent. Query: `limit` (1-200, default 50), `offset`.

```
{
  "leads": [
    { "conversation_id": "9e8d7c6b-...", "email": "guest@example.com", "buying_intent": true, "needs_human": false, "started_at": "...", "last_at": "..." }
  ],
  "next_offset": null
}
```

### GET /agents/{id}/unanswered

Visitor questions the agent could not answer (newest first). Query: `limit` (1-100, default 50).

```
{
  "unanswered": [
    { "message_id": "5a4b3c2d-...", "conversation_id": "9e8d7c6b-...", "question": "Is there a sauna?", "reply": "I don't have that information...", "created_at": "..." }
  ]
}
```

### POST /agents/{id}/unanswered/{messageId}

Answer it: creates an FAQ item (question = the visitor's question unless you send `question`) and removes it from the list. Body: `answer` (required), `question`, `visibility` (default public). Returns 201 `{"item_id": "...", "resolved": true}`.

```
curl -X POST https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/unanswered/$MESSAGE_ID -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" -d '{"answer":"No sauna, but the lake is a 2-minute walk."}'
```

### DELETE /agents/{id}/unanswered/{messageId}

Dismiss without adding knowledge. Returns `{"resolved": true, "message_id": "..."}`.

## Imports and the disclosure policy

An import reads the owner's files and web pages, sorts every detail into a visibility level, and flags details that look private. Nothing goes live until the owner accepts the resulting disclosure policy. Use an import (not `POST /knowledge`) for the owner's real documents.

Paid plans include 2 imports a month (an import counts when its first source is added). Free has none yet: `start_import` returns 403 `upgrade_required`. Up to 100 sources and about 3 MB of text per import.

The flow:

1. `POST /agents/{id}/imports`: start (or get the open import). Returns the import with `id` and `allowance`.
2. `POST /imports/{importId}/sources` once per file or page:
   - `{"kind": "text", "name": "house-rules.md", "text": "..."}` for any file you can read as text
   - `{"kind": "url", "url": "https://example.com/faq"}` for one web page
   - `{"kind": "pdf", "name": "menu.pdf", "base64": "..."}` for a PDF (about 1.4 MB at most through the API)
   Returns `{"source", "added", "flagged", "removed"}`. Passwords, API keys, card and bank numbers are dropped (`removed`) and never stored.
3. `POST /imports/{importId}/finish`: reading is done. Returns the import with `items` and `open_decisions`.
4. For every item with `needs_decision: true`, ask the owner its `question` and send their answer: `PATCH /imports/{importId}/items/{itemId}` with `{"visibility": "public" | "customer" | "internal"}`. Do not decide private details yourself. You may also change any other item's level, or skip it with `{"leave_out": true}`.
5. Ask the owner whether there is anything else the assistant must never talk about, and send it: `POST /imports/{importId}/rules` with `{"rules": ["discuss other guests"]}` (each rule completes "The assistant must never ..."; send `[]` for none).
6. `GET /imports/{importId}/policy`: show the owner the draft (`shared_with_anyone`, `only_if_asked`, `never_shared`, `never_discuss`) or send them `policy_url`, which has Save as PDF.
7. Only after the owner says they accept: `POST /imports/{importId}/accept` with `{"confirm": true}`. The items go live, the never-discuss rules join the persona, and the policy is stored as the next version. Refused (409 `undecided`) while an item still needs a decision.

Later imports apply earlier decisions automatically: a detail of a kind the owner already decided (for example door codes) is filed without asking again.

Other calls:

- `GET /imports/{importId}`: the import, its items and `next_question`.
- `DELETE /imports/{importId}`: cancel an import that is not live (a used monthly import is not given back).
- `GET /agents/{id}/policy` (optional `?version=2`): the accepted policy and the list of `versions`.

`only_if_asked_held_back: true` means the agent's disclosure slider is below 50, so "only if asked" items are not shared at all until the owner raises it.

```
curl -X POST https://tonetuned.rjscode.com/api/v1/agents/$AGENT_ID/imports -H "Authorization: Bearer $TONETUNED_KEY"
curl -X POST https://tonetuned.rjscode.com/api/v1/imports/$IMPORT_ID/sources -H "Authorization: Bearer $TONETUNED_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"text","name":"faq.md","text":"Do you allow pets?\nSmall dogs are welcome for 10 EUR a night."}'
```

## MCP server

The same actions are available as MCP tools at:

```
https://tonetuned.rjscode.com/api/mcp
```

Streamable HTTP, stateless, JSON responses (protocol versions 2025-06-18 and 2025-03-26). Same Bearer key.

Tools: `get_account`, `update_account`, `list_agents`, `create_agent`, `get_agent`, `update_agent`, `delete_agent`, `list_knowledge`, `get_knowledge`, `add_knowledge`, `update_knowledge`, `delete_knowledge`, `preview_chat`, `list_conversations`, `list_leads`, `list_unanswered`, `answer_unanswered`, `dismiss_unanswered`, `start_import`, `add_import_source`, `finish_import_reading`, `get_import`, `decide_import_item`, `add_import_rules`, `get_import_policy`, `accept_import_policy`, `cancel_import`, `get_policy`. Tool arguments use the REST field names, with `agent_id`, `import_id`, `item_id` and `message_id` in place of path ids; `add_knowledge` always takes `items`.

Claude Code:

```
claude mcp add --transport http tonetuned https://tonetuned.rjscode.com/api/mcp --header "Authorization: Bearer tt_live_YOUR_KEY"
```

Cursor (`~/.cursor/mcp.json`) and other clients that take a JSON config:

```
{
  "mcpServers": {
    "tonetuned": {
      "url": "https://tonetuned.rjscode.com/api/mcp",
      "headers": { "Authorization": "Bearer tt_live_YOUR_KEY" }
    }
  }
}
```

Claude Desktop (`claude_desktop_config.json`, through the mcp-remote bridge):

```
{
  "mcpServers": {
    "tonetuned": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://tonetuned.rjscode.com/api/mcp", "--header", "Authorization: Bearer tt_live_YOUR_KEY"]
    }
  }
}
```

## Rate limits

- 120 requests per minute per API key (REST and MCP together).
- Preview: 60 per hour and 300 per day per account, shared with the dashboard.
- Over a limit you get 429 `rate_limited`. Wait and retry; do not loop.

## Errors

Every error has the same shape and a matching HTTP status:

```
{ "error": { "code": "not_found", "message": "Agent not found." } }
```

- 400 `bad_request` (the message names the field), `invalid_json`
- 401 `unauthorized`: missing, malformed or revoked key
- 403 `plan_limit` (agent limit of the plan), `example_read_only` (example agents cannot be changed), `upgrade_required` (imports need a paid plan)
- 409 `undecided` (an import item still needs a decision), `not_in_review`, `import_closed`, `import_done`
- 422 `fetch_failed`, `no_text`, `import_full` and source checks such as `too_short` or `bad_pdf`
- 404 `not_found`: no such id in this account (ids of other accounts look the same as missing ones)
- 413 `payload_too_large`
- 429 `rate_limited`; `limit_reached` (this month's included imports are used)
- 500 `server_error`, 503 `unavailable`: retry later

In MCP, a failed tool call returns a result with `isError: true` and the code and message as text.

## Coming next

- Past-conversation import (learning the owner's writing style) through the API; the owner does it in the dashboard for now.
- Paid extra imports, priced by size.
