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

# Writes and idempotency

> Add, re-word and archive prompts, and change competitors, aliases, segments and engines from a script, acting as the person who made the key.

A key with a write scope can change a project the way Prompts and Project settings do in the app:

| Change | Route | Scope |
| - | - | - |
| Add a prompt | `POST /v1/projects/{projectId}/prompts` | `prompts:write` |
| Re-word a prompt | `POST /v1/projects/{projectId}/prompts/{promptId}/replace` | `prompts:write` |
| Archive a prompt | `POST /v1/projects/{projectId}/prompts/{promptId}/archive` | `prompts:write` |
| Track a competitor | `POST /v1/projects/{projectId}/competitors` | `projects:write` |
| Stop tracking a competitor | `DELETE /v1/projects/{projectId}/competitors/{brandId}` | `projects:write` |
| Add an alias for your brand | `POST /v1/projects/{projectId}/aliases` | `projects:write` |
| Remove an alias | `DELETE /v1/projects/{projectId}/aliases/{aliasId}` | `projects:write` |
| Set the claimed segments | `PUT /v1/projects/{projectId}/segments` | `projects:write` |
| Set the engines sampled | `PUT /v1/projects/{projectId}/engines` | `projects:write` |

Creating projects is not in the API; do that in the app. The MCP server has no write tools at all: writes are REST only.

## A key writes as the person who made it

The write scopes are off when a key is made, and only someone who ticks them gets them. A write then **acts as the person who made the key, with their role**:

* A key never does more than its maker could do in the app. An owner, admin or editor can make these changes; a viewer cannot.
* **The maker is checked again on every write.** If they have left the workspace or become a viewer, the key's writes are refused with `403 forbidden` ("the person who made this key can no longer change this workspace, so the key cannot either"). Its reads carry on. Make a new key as someone who can edit.
* A key with no recorded maker cannot write at all; make a new one.
* Turning on an engine that is an opt-in is a spending decision, so it needs an owner or admin, as in the app.
* Citino records every write against both the person and the key, so a change can always be traced to the key that made it.

Each write is one transaction: the check and the change happen together, or not at all.

Send writes from a server or a script. A write that carries an `Origin` header naming another site, as a request from a browser page does, is refused with `403 forbidden`.

## Prompts

### Add a prompt

**text** `string` *required*

The question, exactly as a buyer would ask it, up to 300 characters. Prompt text never changes once added, so check it before you send it.

**intent** `string` *required*

What the buyer is after: `discover`, `use-case`, `compare`, `alternative`, `pricing`, `trust` or `support`.

**segments** `string[]` *required*

The segment ids the prompt belongs to. Use segments the project has claimed: a prompt only counts towards your scores in a claimed segment. An empty list is accepted, and leaves the prompt out of every score. The project's segments, claimed or not, are on `GET /v1/projects/{projectId}`.

The answer is `201` with the new `prompt_id`, its `cadence`, and `first_run_date: null`: the prompt is collected from the next scheduled day, exactly like one added in the app. `scope` says how Citino classified the question: unbranded questions (the ones behind your headline figures and reports) read `"category"`, a question that names your brand or one competitor reads `"branded"`, and one that names two or more tracked brands reads `"versus"`.

The refusals worth handling:

* **The same text again** is `409 conflict` with `detail.existing_prompt_id` (and `detail.first_run_date`). Identity is the exact text, so a retried script converges on that id. The match includes prompts you have archived or re-worded, which are not collected, so check that prompt's `status` on the prompts list before treating it as live. Normalise your text (trim it, settle on one spelling) before you send it, because a different capital letter is a different prompt.
* **A segment the project has not claimed** is `409 conflict`; `detail.unclaimed` and `detail.claimed` list both.
* **At the plan's prompt limit** is `402 plan_limit`, with `detail.used`, `detail.limit` and `detail.actions` naming the two ways out, `upgrade` and `archive`. The limit is checked before the duplicate, so at the limit even the same text again is `402`, not `409`.
* **An archived project** takes no new prompts: `409 conflict`.

```bash curl theme={null}
curl -X POST "https://api.citino.ai/v1/projects/$PROJECT_ID/prompts" -H "Authorization: Bearer $CITINO_API_KEY" -H "Content-Type: application/json" -d "{\"text\": \"$PROMPT_TEXT\", \"intent\": \"compare\", \"segments\": [\"$SEGMENT_ID\"]}"
```

```python Python theme={null}
import os
import requests

BASE = "https://api.citino.ai/v1"
key = os.environ["CITINO_API_KEY"]
project_id = os.environ["PROJECT_ID"]


def add_prompt(text, intent, segments):
    """Add a prompt, or return the id of the identical one already there."""
    response = requests.post(
        f"{BASE}/projects/{project_id}/prompts",
        headers={"Authorization": f"Bearer {key}"},
        json={"text": text.strip(), "intent": intent, "segments": segments},
    )
    if response.status_code == 409:
        detail = response.json()["error"].get("detail", {})
        if "existing_prompt_id" in detail:
            return detail["existing_prompt_id"]
    response.raise_for_status()
    return response.json()["prompt_id"]


print(add_prompt(os.environ["PROMPT_TEXT"], "compare", [os.environ["SEGMENT_ID"]]))
```

```javascript JavaScript theme={null}
const BASE = "https://api.citino.ai/v1";
const headers = {
  Authorization: `Bearer ${process.env.CITINO_API_KEY}`,
  "Content-Type": "application/json",
};

// Add a prompt, or return the id of the identical one already there.
async function addPrompt(text, intent, segments) {
  const response = await fetch(`${BASE}/projects/${process.env.PROJECT_ID}/prompts`, {
    method: "POST",
    headers,
    body: JSON.stringify({ text: text.trim(), intent, segments }),
  });
  const body = await response.json();
  if (response.status === 409 && body.error.detail?.existing_prompt_id) {
    return body.error.detail.existing_prompt_id;
  }
  if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
  return body.prompt_id;
}

console.log(await addPrompt(process.env.PROMPT_TEXT, "compare", [process.env.SEGMENT_ID]));
```

### Re-word a prompt

Because prompt text never changes, a re-wording is a **new prompt that takes the old one's place**. Send `text`, a wording the project does not already track (archived and replaced prompts count, and so does the prompt's own wording); one it does track is `409 conflict` with `detail.existing_prompt_id`, as when adding. Otherwise the answer is `201` with the new `prompt_id` and `replaces_id`, the prompt it replaced. The old prompt stops being collected, keeps its history, and is marked as replaced by the new one. Compare the two over time by their ids.

### Archive a prompt

`POST …/prompts/{promptId}/archive` with no body stops collecting a prompt and answers `{"archived": true}`. Its history stays. A prompt that is not active (already archived, or replaced) is `404`.

## Competitors

### Track a competitor

Send `brand_id`: the `id` of any brand a report or the league table has returned. Competitors are tracked **by id only**; to track a brand Citino has never seen named, add it by name in the app.

* `201` with the brand's `brand_id`, `name` and `domain` when it was added, and **`200` with the same body when it was already tracked**, so a retry is safe.
* An unknown brand id is `404`; your own brand is `400`, with `field` set to `brand_id`; at the plan's competitor limit, `402 plan_limit` with `detail.limit` and `detail.used`.

### Stop tracking a competitor

`DELETE …/competitors/{brandId}` answers `204`. Nothing measured is deleted. A brand that is not tracked is `404`.

## Aliases

An alias is another name answers use for your brand: a short form, an old name, a common misspelling.

* `POST …/aliases` with `{"alias": "…"}` answers `201` with `alias_id`, `brand_id` and `rescan`, a sentence saying the answers already collected are being re-scanned for the new name (or, when the re-scan could not be queued just then, that the alias is saved and how to start the re-scan). The re-scan reads stored answers only: no engine is asked anything and no prompt allowance is used.
* An alias that already belongs to another brand is `409 conflict`, and `detail.owning_brand_id` and `detail.owning_brand_name` name that brand. An alias is 3 to 128 characters; outside that is `400 validation`.
* `DELETE …/aliases/{aliasId}` answers `204`. Only an alias your workspace added that has not been reviewed yet can be removed (`source` is `customer` and `reviewed` is `false`); any other id is `404`. Your project's aliases and their ids are on `GET /v1/projects/{projectId}`; read them before adding, so a re-run does not add twice.

## Segments and engines

Both are `PUT`s that **replace the whole list**, so sending the same body twice leaves the same result.

* `PUT …/segments` with `{"segments": [ids]}` sets the segments the project claims: at least one, taken from the project's segments on `GET /v1/projects/{projectId}`. An id the project does not have is `400 validation` with `field` set to `segments` and the id in the message, and nothing changes; an id sent twice is claimed once. Your scores are recomputed over the history already stored on the next read; nothing new is collected. The answer has `claimed` and `recomputed`, a sentence saying so.
* `PUT …/engines` with `{"engines": [keys]}` sets the engines the project samples from now on. Turning one off removes nothing already measured. At least one must be an engine that is being collected (`400` otherwise); an opt-in engine your plan does not carry is `402`. The answer has `engines`, as stored, and `history`, a sentence saying what happened to past answers. `GET /v1/engines` lists every engine and how a project gets it.

## Retrying safely

| Write | Safe to repeat? |
| - | - |
| Add a prompt | Yes: the second time is `409` with the existing prompt's id, or `402` if the workspace is at its prompt limit, which is checked first. |
| Re-word a prompt | No: the first call retires the old prompt, so a second is `404`. Check for the new id before retrying. |
| Archive a prompt | The second call is `404`, which means it is already done. |
| Track a competitor | Yes: the second time is `200` with the same body. |
| Stop tracking | The second call is `404`, which means it is already done. |
| Add an alias | Read the project's aliases first; do not rely on a repeat converging. |
| Remove an alias | The second call is `404`, which means it is already gone, or is not one you can remove. |
| Set segments, set engines | Yes: a `PUT` replaces the list. |

## Related

> [card] [Authentication](https://docs.citino.ai/api/authentication.md)
>
> Making a key with a write scope.

> [card] [Errors](https://docs.citino.ai/api/errors.md)
>
> `conflict`, `plan_limit` and `forbidden`.

> [card] [Project settings](https://docs.citino.ai/guides/screens/project-settings.md)
>
> The same changes, in the app.

> [card] [Plans and limits](https://docs.citino.ai/guides/account/plans-and-limits.md)
>
> Prompt and competitor limits.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.