# Altalandi for AI agents

Everything an agent needs to use Altalandi correctly, on one page.

Altalandi turns English, or Icelandic that sounds translated or machine-written,
into Icelandic that reads as if a native speaker wrote it. It checks that the
meaning survived, and it refuses to return a rewrite it has found to be broken.

- Endpoint: `POST https://altalandi.is/api/v1/refine`
- Full reference: https://altalandi.is/llms-full.txt
- OpenAPI: https://altalandi.is/api/openapi.json

## When to call it, and when not to

Call it for any Icelandic a person will read: web and app copy, UI strings,
emails, product text, marketing, children's stories, and anything you
translated into Icelandic yourself.

Don't call it for code, identifiers, data that must stay byte-for-byte literal,
or quotations that must not be reworded. It may rephrase, so check the diff.

## 1. Connect (once)

### On a person's computer (Claude Code, Codex, Cursor, a terminal): do this

**Do not ask the person for an API key, and do not tell them to set
`ALTALANDI_API_KEY`.** The login below gets a key onto this computer without it
ever appearing in the chat, a file you write, or the person's clipboard.

1. Run `npx altalandi login` **in the background** (it waits up to 10 minutes
   for the person). It never asks a question on the terminal.
2. Read its output and show the person **the link and the code** it prints. It
   also tries to open the link in their browser. They sign in or create an
   account (new accounts get 2,000 free units a day and 500 kr. of trial
   credit), check that the code matches, and click **Tengja**.
3. Wait for the command to exit `0`. The key is now stored for this user
   (`%APPDATA%\altalandi\credentials.json` on Windows,
   `~/.config/altalandi/credentials.json` elsewhere), and, if Claude Code is set
   up, the `/altalandi` skill is installed in `~/.claude/skills/altalandi/`.
4. Add the MCP tool. It reads the stored login, so **no key goes in any config**:
   - Claude Code: `claude mcp add altalandi -s user -- npx -y altalandi-mcp@latest`
   - Codex: `codex mcp add altalandi -- npx -y altalandi-mcp@latest`
   - Cursor and other MCP clients: the JSON under "MCP" below.
5. Check it: `npx altalandi whoami` prints the account and balance.

For your own work, use the MCP tool (`refine_icelandic`) or the CLI with
`--review`. Both return one Markdown block made to be shown as it is: the
verdict, the text before and after, every change with its reason, what the call
cost and the balance left. Paste it to the person unchanged, then act on the
outcome. A new MCP server is usually available from the next session.

### On a server or in CI

Create an account at https://altalandi.is/portal, click **Búa til lykil**
("create key"), and put the key (`rtv_<id>_<secret>`, shown once) in the
environment as `ALTALANDI_API_KEY` from a secret manager. This is also how code
you write into a project (the HTTP examples below) gets its key.

Never put the key in a prompt, a chat message, a command-line argument or a
committed file. Command-line arguments leak through shell history and process
lists. Existing keys keep working; there is no rotation schedule.

## 2. Call it — three ways

### HTTP (works from anything)

```bash
curl -s https://altalandi.is/api/v1/refine \
  -H "Authorization: Bearer $ALTALANDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Make sure to reach out to us today.", "domain": "marketing"}'
```

### CLI (from an agent: `--review`)

```bash
npx -y altalandi@latest --review --file in.txt --out out.txt
```

Write the text to a UTF-8 file first; never pass it on the command line. stdout
is the block to show the person as it is (verdict, before, after, each change
with its reason, cost and balance); `out.txt` holds the refined text to apply.
Exit codes: `0` rewritten and verified, or no change needed; `3` the rewrite was
refused or the meaning could not be verified (keep the original); `4` out of
credit; `2` not logged in or no input; `1` request failed. `--json` prints the
full API response instead, for code that parses it.

A whole folder, i18n catalog or website in one run: `npx -y altalandi@latest
./docs --dry-run` prints the estimate; the same command with `--yes` writes the
results to `./docs-is` (only changes whose meaning check passed) and one report.
Code, placeholders, tags and JSON keys come back byte for byte.

Keep the CLI current with `npx altalandi update`. It asks before changing
anything; in a non-interactive run it only reports.

### Test keys

A key made with environment **test** (dashboard → API lyklar og skjölun, with the
switch on Prófun) never runs a model and is never charged. The response has the
same shape, `output` is the input unchanged, `test: true`, and `meaningPreserved`
is `null`. Use it to wire up and test an integration; switch to a live key for real
text.

### Showing the result

After every call, show the person what changed: the text before and after, and the
numbered `diffs` (before, after, reason). `altalandi --review` and the MCP tool
print that block for you, with the cost and the balance; `altalandi --diff` prints
it on stderr for a person at a terminal; `altalandi --report` (or `ALTALANDI_REPORT=1`) also writes a Markdown
report to `./altalandi-reports/`. The MCP tool takes `report: true` for the same.

### ChatGPT

In ChatGPT, Altalandi is a custom MCP connector. The person makes their own
connector link at https://altalandi.is/portal#chatgpt, turns on Developer mode in
ChatGPT's settings, then adds it under Plugins, Add, Create custom MCP server.
The link is billed to their account like a key; treat it as a secret. Send one
`tools/call` per request: batches with more than one refine are refused.

### MCP (Claude Code, Cursor, any MCP client)

```json
{ "mcpServers": { "altalandi": { "command": "npx", "args": ["-y", "altalandi-mcp@latest"] } } }
```

This exposes the `refine_icelandic` tool. The server finds its key by itself:
`ALTALANDI_API_KEY` if the environment has it, otherwise the key stored by
`npx altalandi login`. Never put a key in this JSON. From `altalandi-mcp` 0.1.2 the tool states the outcome on its
first line and reports a refused or unverified result as an error. Older
versions lead with "meaning preserved" even when a rewrite was refused, so read
their warnings.

## 3. The request

Only `text` is required.

| field | values | default | use it when |
|---|---|---|---|
| `text` | string, up to 20,000 characters | required | always |
| `domain` | `general` · `marketing` · `data-analysis` · `childrens-fiction` | `general` | you know what kind of text it is |
| `register` | `formal` · `neutral` · `warm` | `neutral` | tone matters |
| `sourceLang` | `auto` · `en` · `is` | `auto` | detection could guess wrong, e.g. very short text |
| `glossary` | `[{ "en": "...", "is": "...", "note": "..." }]` | `[]` | a term must always be translated one way (max 50) |

Leave everything else at its default. In particular, don't switch off
`preserveMeaning` or `scoreNativeness` for text a person will read: that removes
the safety checks described below.

## 4. Read the result — this is the part that matters

Don't look at one field. Work out which of four outcomes happened, **in this
order**:

| # | check | outcome | what to do |
|---|---|---|---|
| 1 | `quality.encodingArtifacts` is not empty | **unusable** | discard the output and report an error |
| 2 | `rejectionReason` is set, or `meaningPreserved` is `false` | **refused** | the original came back unchanged; tell the user it was *not* rewritten, and why |
| 3 | `rewriteAttempted` is `false` | **already fine** | nothing needed changing; that's a success |
| 4 | `meaningPreserved` is not `true` | **unverified** | the meaning could not be checked; don't present the output as verified |
| – | otherwise | **rewritten** | use `output`, and show the user the `diffs` |

Why `meaningPreserved` alone is not enough: when Altalandi refuses a broken
rewrite (an invented word, say), the meaning check has *already passed*. The
response then has `meaningPreserved: true` and returns your original text. Only
`rejectionReason` shows that the rewrite was thrown away.

`rejectionReason` starts with one of:

- `MEANING DRIFT` — the rewrite changed what the text says
- `HARD INVARIANT LOST` — a number, date, time, negation, name or obligation
  (`skal`, `óheimilt` …) did not survive
- `UNVERIFIED` — the meaning check could not complete, so nothing was risked
- `CANDIDATE VETO` — the rewrite itself was broken; `candidateVetoSignal` says
  how: `morphology` (a word that isn't Icelandic), `concrete_nativeness_tell`
  (a specific quoted error) or `encoding_artifacts`

**English input works a little differently.** Returning English to someone who
asked for Icelandic would be useless, so a translation whose meaning can't be
verified comes back with `meaningPreserved: null` and a warning, and counts as
unverified (row 4). A translation that clearly changed the meaning is retried
once with a stronger model; if that also fails, the API returns `502` with no
text.

### A correct wrapper

```python
import os, requests

def refine_icelandic(text: str, **options) -> dict:
    r = requests.post(
        "https://altalandi.is/api/v1/refine",
        headers={"Authorization": f"Bearer {os.environ['ALTALANDI_API_KEY']}"},
        json={"text": text, **options},
        timeout=300,
    )
    r.raise_for_status()
    data = r.json()

    if data["quality"].get("encodingArtifacts"):
        outcome = "unusable"
    elif data.get("rejectionReason") or data.get("meaningPreserved") is False:
        outcome = "refused"
    elif data.get("rewriteAttempted") is False:
        outcome = "already_fine"
    elif data.get("meaningPreserved") is not True:
        outcome = "unverified"
    else:
        outcome = "rewritten"

    return {
        "outcome": outcome,
        "text": data["output"],                 # the original when refused or already fine
        "reason": data.get("rejectionReason", ""),
        "changes": data.get("diffs", []),
        "warnings": data.get("warnings", []),
        "id": data["id"],                        # quote this when reporting a problem
    }
```

```ts
type Outcome = "unusable" | "refused" | "already_fine" | "unverified" | "rewritten";

export async function refineIcelandic(text: string, options: Record<string, unknown> = {}) {
  const res = await fetch("https://altalandi.is/api/v1/refine", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.ALTALANDI_API_KEY}`,
    },
    body: JSON.stringify({ text, ...options }),
    signal: AbortSignal.timeout(300_000),
  });
  if (!res.ok) throw new Error(`Altalandi ${res.status}: ${(await res.json()).detail}`);
  const data = await res.json();

  const outcome: Outcome =
    data.quality?.encodingArtifacts?.length ? "unusable"
    : data.rejectionReason || data.meaningPreserved === false ? "refused"
    : data.rewriteAttempted === false ? "already_fine"
    : data.meaningPreserved !== true ? "unverified"
    : "rewritten";

  return { outcome, text: data.output, reason: data.rejectionReason ?? "",
           changes: data.diffs ?? [], warnings: data.warnings ?? [], id: data.id };
}
```

## 5. Expose it as a tool

Return the **outcome** to the model, not the raw JSON. A model given raw JSON
tends to read `meaningPreserved: true` and report success.

```json
{
  "name": "refine_icelandic",
  "description": "Rewrite English or unnatural Icelandic into native-sounding Icelandic without changing its meaning. Returns an outcome: 'rewritten' (use the text), 'already_fine' (no change needed), 'refused' (the original is returned because a rewrite would have been wrong — tell the user why), 'unverified' (do not present as checked) or 'unusable'.",
  "input_schema": {
    "type": "object",
    "properties": {
      "text": { "type": "string", "description": "The text to refine." },
      "domain": { "type": "string", "enum": ["general", "marketing", "data-analysis", "childrens-fiction"] },
      "register": { "type": "string", "enum": ["formal", "neutral", "warm"] }
    },
    "required": ["text"]
  }
}
```

## 6. Signals worth knowing

- **Treat the nativeness score as a hint.** `quality.nativenessScore` (0–100)
  and `translationeseTells` point at likely problems, but the score can be
  confidently wrong in both directions. A tell that quotes a specific phrase is
  worth far more than the number. Never use a low score to override a refusal.
- **`presentationDrift`** lists formatting-only changes that kept the value
  (for example a date written differently). They are safe, but a user may want
  to know.
- **`invariantChecks`** shows each number, date, name and obligation that was
  checked, and whether it survived.

## 7. Limits, errors and cost

| status | meaning | what to do |
|---|---|---|
| `400` | empty `text` | send text |
| `401` | missing or invalid key | check `ALTALANDI_API_KEY`; don't retry |
| `402` | `insufficient_balance`: account funding is insufficient for this call | stop and tell the person; waiting won't help. They add credit in the portal. If the message says an automatic top-up has started, try once more in a minute |
| `413` | text too long | split at paragraph boundaries, then call once per part |
| `422` | invalid request, or glossary over its limits | fix the request; don't retry |
| `429` | a usage limit was reached: calls at once or per minute per account (until an account has paid: 2 at once, 10 a minute; after: 4 and 60), or an operator key's daily grant | wait for the `Retry-After` header, then retry |
| `502` | a model provider failed, or an English translation could not be verified | retry once later |
| `503` | the service is not configured | retry later |

- **Cost:** past the free units (below), calls are paid from the account's
  prepaid credit, held in krónur. A call costs its billable words × the tariff (0.08 kr per word by
  default; `GET /api/v1/pricing` has the current figures). Every call counts at
  least 40 words, however short the text, because the full set of checks runs
  either way. The `max` writer and `rigorous` scoring multiply the count (see
  `billedTier`).
- **Free tier:** every account gets 2,000 free units a day on the standard
  writer (about 2,000 words), shared by its keys, the signed-in web tool and its
  ChatGPT link. The `max` writer is always paid from credit. Until the account
  has paid, it is limited to 10 calls a minute and 2 at once. `GET /v1/pricing`
  returns the live numbers.
- **Codes:** a person may redeem a code in the portal (Kóðar og stillingar). A
  code can make use free within its own limits, discount everything the credit
  pays, or add credit once. Nothing changes in how you call the API.
- **Credit:** past the free units, calls are paid from credit. A new account
  also starts with 500 kr of trial credit, once. More credit is bought in the
  portal (1,000 to 100,000 kr; larger top-ups earn +5% to +15% extra credit)
  and can top itself up from a saved card when it runs low, up to a monthly
  maximum the account sets. See usage and credit in the portal.
- **Free use is kept to improve the service.** A call covered by the free daily
  units is kept with its output in Altalandi's text bank, with personal data
  (kennitölur, emails, phone, card and account numbers, API keys) replaced by
  placeholders, unless it sends `"contribute": false` or the account has opted
  out in the portal. Paid calls (from credit) are kept only with
  `"contribute": true` or account-wide consent. Send `contribute: false` when
  the person asks that their text not be kept, or when it is confidential; never
  send `contribute: true` without their explicit consent. ChatGPT text is never
  kept.
- **Many texts:** there is no public batch endpoint. Make one call per text,
  with at most 4 in parallel. Stop at the first `402`; on `429`, wait for
  `Retry-After`.
- **Speed:** a fully checked call makes several model calls and can take several
  seconds; allow up to 300 seconds. Don't disable the checks to go faster for
  text people will read.

## 8. What Altalandi records

**Text:** free use is kept in the text bank, scrubbed of personal data, for up
to three years (see "Free use is kept" above); paid use only with consent;
ChatGPT text never. **Never stored:** passwords, session tokens and key secrets.

**Recorded, per call, so usage and cost can be accounted for and abuse
spotted:** time, key id, account, client IP address, text length, the models
used, tokens, estimated provider cost, and the outcome. Client IP addresses are
deleted after 90 days; the rest of the record is kept for cost history.

Sign-ups, logins (including failed attempts), key creation and revocation are
also recorded, with the IP address.

Your text is sent to the model providers that do the rewriting, under their
terms. Banked text may be used to review and correct translations, test new
versions, and train or tune the models Altalandi uses. Full policy:
https://altalandi.is/personuvernd#textabanki

## 9. When something looks wrong

Every response has an `id` like `r_1a2b3c`. Quote it when reporting a problem;
it lets the operator find the record without you sending the text again.
