# AI calls in actions

Source: https://dabadata.com/docs/ai/ (HTML) - https://dabadata.com/docs/ai/index.md (this page as markdown)

An [action](https://dabadata.com/docs/rules/index.md#actions-protecting-counters-capacity-and-stock) can end with one call to a language model - Claude (Anthropic), OpenAI or Gemini (Google) - made by the dabadata server with your API key. The definition fixes the provider, the model, the system prompt, the prompt template and the token budget; your app's users call the action by name with its params, and the answer comes back in the response or is written into a row that live subscribers see change. No server or function of your own, and the key never reaches a browser.

## Why

Calling a model from a browser means shipping the provider key to every visitor, so it usually takes a backend of your own just to hold the key and forward one request. With an `ai` block on an action, dabadata is that backend: the key is a [project secret](https://dabadata.com/docs/projects/index.md#secrets), stored encrypted and never returned by any API, and the server sends it only to the provider's fixed https host.

The client sends nothing but the action's params. A publishable key in a browser cannot change the model, the prompt, the system prompt or the token budget, and the action's `allow` rule, its `check` steps and its [limits](https://dabadata.com/docs/ai/index.md#limits) decide who may spend your tokens and how many.

- Summarize, classify, translate or tag a row the caller owns, and store the result in the row.
- Answer a question right away (the response waits for the model).
- Insert a row and fill one of its columns with generated text in the background.

## Example: summarize a note

A note app where each person may ask for a short summary of their own notes. The summary is written into the note, and a status column tells the page what is happening. First store the Anthropic key as a project secret of the environment (live and test have separate secrets; the definition below is refused until the secret exists):

```bash
curl -X PUT https://dabadata.com/v1/project/secrets/ANTHROPIC_KEY \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"<your Anthropic API key>"}'
```

Or in the console: **Secrets** > **AI provider keys**, paste the key in the Anthropic row (it is saved as `ANTHROPIC_KEY`; OpenAI and Gemini have rows of their own, and **Other secrets** takes any name). Then apply the definition (`PUT /v1/schema`, or the console's definition editor):

```json
{
  "roles": ["member"],
  "tables": {
    "notes": {
      "columns": {
        "title":          { "type": "text" },
        "body":           { "type": "text" },
        "author":         { "type": "userId" },
        "summary":        { "type": "text", "optional": true },
        "summary_status": { "type": "text", "default": "pending" }
      },
      "rules": { "read": { "owner": "author" }, "create": "authenticated", "update": { "owner": "author" } }
    }
  },
  "actions": {
    "summarize": {
      "allow": "authenticated",
      "params": { "note": { "type": "ref", "table": "notes" } },
      "steps": [
        { "op": "check", "table": "notes", "id": "$params.note", "expect": { "author": "$user.id" } }
      ],
      "ai": {
        "provider": "anthropic",
        "model": "claude-haiku-5-5",
        "secret": "ANTHROPIC_KEY",
        "system": "Summarize the note in three short lines.",
        "prompt": "{{row.title}}\n\n{{row.body}}",
        "row": { "table": "notes", "id": "$params.note" },
        "maxTokens": 400,
        "saveTo": { "table": "notes", "id": "$params.note", "column": "summary", "status": "summary_status" },
        "limit": { "perUser": "20/hour", "total": "500/hour" }
      }
    }
  }
}
```

- The `check` step makes sure the caller wrote the note. Without it anyone signed in could have any note summarized, because `saveTo` writes with the server's rights (see [Security](https://dabadata.com/docs/ai/index.md#security)).
- `row` is read **as the caller** after the steps, and `{{row.title}}` / `{{row.body}}` fill the prompt from it.
- `saveTo` makes the call run in the background: the request answers at once, and the server writes the answer into `summary` and `"done"` into `summary_status` when the model is finished (`"failed"` if it fails).
- `summary_status` defaults to `"pending"`, so a new note shows "pending" until it has been summarized once.

In the browser, subscribe to the notes and call the action. The answer arrives as an ordinary live update of the row:

```js
import { createClient } from "https://dabadata.com/client/dist/dabadata.js";
const db = createClient({ url: "https://dabadata.com", key: "pk_live_EXAMPLEexample0000000000" });
// Signed in with db.auth: the read rule shows each person their own notes.

db.subscribe("notes", {}, {
  onRows(rows) { rows.forEach(show); },
  onChange({ op, row }) { if (op !== "delete") show(row); },
});

function show(n) {
  // summary_status: "pending" -> "done" (summary filled in) or "failed"
  render(n.id, n.title, n.summary_status === "done" ? n.summary : n.summary_status);
}

const note = (await db.insert("notes", { title: "Trip", body: "Paris on Monday, then Lyon." })).id;
const { ai } = await db.action("summarize", { note });
// ai = { status: "queued", id: "17" }: the summary comes in through onChange
```

A moment later the subscription receives the update the server wrote:

```json
{ "op": "update",
  "row": { "id": "5c1d...", "title": "Trip", "body": "Paris on Monday, then Lyon.", "author": "usr_...",
           "summary": "A trip in two stops.\nParis on Monday.\nThen Lyon.",
           "summary_status": "done", ... } }
```

To summarize again, call the action again; set `summary_status` back to `"pending"` in a step first if the page should show progress for the second run too (an `update` step on the same row, after the check).

Without `saveTo` the response waits for the model and carries the text. An action may also have no steps at all:

```json
"translate": {
  "allow": "authenticated",
  "params": { "text": { "type": "text" }, "lang": { "type": "text" } },
  "ai": {
    "provider": "openai",
    "model": "gpt-5-mini",
    "secret": "OPENAI_KEY",
    "system": "Translate the user's text. Answer with the translation only.",
    "prompt": "Target language: {{params.lang}}\n\n{{params.text}}",
    "maxTokens": 600
  }
}
```

```js
const { ai } = await db.action("translate", { text: "Good morning", lang: "French" });
if (ai.status === "done") console.log(ai.text);      // "Bonjour"
```

The same action on Gemini changes three lines (the key from [Google AI Studio](https://aistudio.google.com/apikey), stored as `GEMINI_KEY`):

```json
"provider": "gemini",
"model": "gemini-2.5-flash",
"secret": "GEMINI_KEY",
```

## The API key

The key is named in the definition, never written in it. Exactly one of:

| Key | Where | Meaning |
| --- | --- | --- |
| `secret` | Hosted projects | The name of a [project secret](https://dabadata.com/docs/projects/index.md#secrets) (`^[A-Z][A-Z0-9_]{0,63}$`) holding the key. It must exist in that environment when the definition is applied: `secret ANTHROPIC_KEY is not set (store it with PUT /v1/project/secrets/ANTHROPIC_KEY)`. |
| `secretEnv` | The default project of a [self-hosted](https://dabadata.com/docs/self-hosting/index.md) server only | The name of an environment variable of the server holding the key (for example `ANTHROPIC_KEY` in its `.env`). It must be set when the definition is applied: `environment variable ANTHROPIC_KEY (secretEnv) is not set`. On a hosted project it is refused: `secretEnv is not available on hosted projects - store the key with PUT /v1/project/secrets/ANTHROPIC_KEY and use secret`. |

Store the key with `PUT /v1/project/secrets/<NAME> { "value" }` (the [HTTP API](https://dabadata.com/docs/api/index.md#project-secrets-put), with the environment's secret key) or the console's **Secrets** page. A stored name can be replaced at any time; calls use the new value at once on the server that took the change, and within a minute everywhere. Deleting it is allowed, and calls then fail with `secret ANTHROPIC_KEY is not set`.

At call time the value must be 1 to 4,096 printable ASCII characters without spaces (a pasted newline counts), else the call fails with `the value of secret ANTHROPIC_KEY is not a valid API key` and nothing is sent. Usage is billed by the provider to the account the key belongs to; `maxTokens` and the [limits](https://dabadata.com/docs/ai/index.md#limits) keep that bill bounded.

## The ai block

`actions.<name>.ai` takes these keys and no others. An action needs `steps` or `ai`; with `ai`, `steps` may be left out or empty. Everything is checked when the definition is applied, and every problem is reported at once.

| Key | Default | Rules |
| --- | --- | --- |
| `provider` | required | `"anthropic"`, `"openai"` or `"gemini"`. Anthropic is called at `POST https://api.anthropic.com/v1/messages` (header `x-api-key`, `anthropic-version: 2023-06-01`, `system`, one user message, `max_tokens`); OpenAI at `POST https://api.openai.com/v1/chat/completions` (`Authorization: Bearer`, a system and a user message, `max_completion_tokens`); Gemini at `POST https://generativelanguage.googleapis.com/v1beta/models/<model>:generateContent` (header `x-goog-api-key`, `systemInstruction`, one user content, `generationConfig.maxOutputTokens`; on Gemini's thinking models `maxTokens` covers the thinking too, so leave room). |
| `model` | required | A model id matching `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,99}$`, for example `claude-haiku-5-5`, `gpt-5-mini` or `gemini-2.5-flash`. A Gemini model goes into the URL, so it takes no `/` or `:` (`gemini-2.5-flash`, not `models/gemini-2.5-flash`). Not checked against a list: the provider answers an unknown model with an error, which the call reports. |
| `secret` / `secretEnv` | required, exactly one | The name of the key; see [The API key](https://dabadata.com/docs/ai/index.md#the-key). |
| `system` | none | The system prompt, a literal of at most 20,000 characters. It takes **no placeholders**: `system takes no placeholders (nothing a caller sends reaches the system prompt) - put {{ }} in prompt`. |
| `prompt` | required | The user message, 1 to 20,000 characters, with [`{{ }}` placeholders](https://dabadata.com/docs/ai/index.md#placeholders). Rendered, at most 100,000 characters. |
| `row` | none | `{ table, id }`: a row read **as the caller** after the steps commit, for `{{row.<column>}}`. `id` takes the forms of a step's `id`: a row id, a placeholder such as `"$params.note"`, or `{ "$ref": "<ref>" }` of an insert step. It is read like `GET /v1/db/<table>/<id>` (read rule, column read rules, [strict tables](https://dabadata.com/docs/rules/index.md#strict-tables), traced as a read); a row the caller may not read, or a missing one, fails the call. |
| `maxTokens` | `1024` | An int from 1 to 32,000: the most the model may write. An answer cut by it has `stopReason: "max_tokens"`. |
| `saveTo` | none (the response waits) | `{ table, id, column, status?, sources? }`: where the answer goes, and the call then runs [in the background](https://dabadata.com/docs/ai/index.md#sync-and-background). `table` a declared table (not a storage bucket's `files_<bucket>` or the `users` profiles table), `column` a `text` column, `status` another `text` column, `sources` (with [web search](https://dabadata.com/docs/ai/index.md#web-search)) a `json` column for the cited pages, `id` as for `row`. |
| `limit` | `{ "perUser": "30/hour", "total": "1000/hour" }` | Each `"<n>/minute"`, `"<n>/hour"` or `"<n>/day"` with n from 1 to 100,000; either may be left out and keeps its default. See [Limits](https://dabadata.com/docs/ai/index.md#limits). |
| `tools` | none | `{ "webSearch": { "maxUses"?, "allowedDomains"? } }`: the model may search the web before it answers. See [Web search](https://dabadata.com/docs/ai/index.md#web-search). |

An id in `row` or `saveTo` needs a value, so an optional param cannot be one (`"$params.n" may be missing (an optional param or $user.name) - an id needs a value`), and a `$ref` must name an earlier insert step of the action. Some validation messages:

```
actions.x.ai: unknown key "url" (keys: provider, model, secret, secretEnv, system, prompt, row, maxTokens, saveTo, limit, tools)
actions.x.ai.provider must be one of anthropic, openai, gemini
actions.x.ai.prompt: {{params.nope}} names no param "nope"
actions.x.ai.prompt: {{row.body}} needs actions.x.ai.row (the row to read)
actions.x.ai.maxTokens must be an int 1..32000
actions.x.ai.saveTo.column: notes.words is int, the answer goes in a text column
actions.x.ai.limit.perUser must be "<n>/minute", "<n>/hour" or "<n>/day" with n 1..100000 (default 30/hour)
actions.x.steps must be a non-empty list of steps (at most 20), or give ai
```

## Web search

With `tools.webSearch` the model may search the web before it answers, for questions the tables cannot answer: today's prices, opening hours, a restaurant's address. It works the same way on all three providers; each runs the search itself and bills it to your key next to the tokens.

```json
"recommend": {
  "allow": "authenticated",
  "params": { "trip": { "type": "ref", "table": "trips" }, "q": { "type": "text" } },
  "steps": [ { "op": "insert", "table": "answers", "values": { "trip": "$params.trip", "question": "$params.q" }, "ref": "a" } ],
  "ai": {
    "provider": "anthropic",
    "model": "claude-haiku-5-5",
    "secret": "ANTHROPIC_KEY",
    "system": "You help a group plan a trip. Answer briefly, with addresses and prices.",
    "prompt": "{{params.q}}",
    "tools": { "webSearch": { "maxUses": 3 } },
    "saveTo": { "table": "answers", "id": { "$ref": "a" }, "column": "text", "status": "status", "sources": "links" }
  }
}
```

The answer goes into `answers.text` and the pages it cites into `answers.links` (a `json` column) as `[{ "url", "title" }]`. Show them under the answer: the providers ask that answers drawn from the web show their sources.

| Key | Default | Rules |
| --- | --- | --- |
| `maxUses` | 3 | An int 1..10: at most this many searches per call. Anthropic enforces it; OpenAI and Gemini have no such cap and decide how many searches to run. |
| `allowedDomains` | any site | 1..20 bare hostnames, optionally with a path (`example.com`, `example.com/blog`, no `https://`): only results from those sites. Anthropic and OpenAI; Gemini has no domain filter, so with `gemini` it is an error: `tools.webSearch.allowedDomains is not available with gemini`. |

How each provider is called with web search on:

| Provider | Request | Sources from |
| --- | --- | --- |
| Anthropic | The `web_search_20250305` server tool on the same `/v1/messages` call (every Claude model). A long search turn that pauses (`pause_turn`) is sent back to continue, at most 3 times. The answer is the text after the last search. | The `web_search_result_location` citations |
| OpenAI | The Responses API, `POST https://api.openai.com/v1/responses`, with the `web_search` tool (Chat Completions only searches with special search models). | The `url_citation` annotations |
| Gemini | Grounding with Google Search (`googleSearch`) on the same `generateContent` call. | `groundingMetadata.groundingChunks` (Google's redirect links) |

A waited-for answer carries the sources and the number of searches run:

```json
"ai": { "status": "done", "text": "Figlmüller, Wollzeile 5, about 25 EUR ...", "stopReason": "end",
        "usage": { "inputTokens": 5210, "outputTokens": 180, "webSearches": 2 },
        "sources": [ { "url": "https://...", "title": "..." } ], "traceId": 977 }
```

Searches cost money on your key (Anthropic: $10 per 1,000 searches, plus the search results as input tokens; see each provider's pricing), so keep `maxUses` low and the action's [limit](https://dabadata.com/docs/ai/index.md#limits) tight. An organization admin can turn web search off in the provider's console; the call then fails with the provider's message.

## Placeholders in the prompt

`prompt` is filled in on the server, after the steps commit. These are the only placeholders; spaces inside the braces are allowed (`{{ user.name }}`), and anything else between `{{` and `}}` is a validation error (`unknown placeholder {{secret.KEY}}`):

| Placeholder | Value |
| --- | --- |
| `{{params.<name>}}` | A param of the action, as sent (and type-checked). |
| `{{user.id}}`, `{{user.role}}`, `{{user.name}}` | The caller. |
| `{{now}}` | The server's time as an ISO string, the same instant as the steps' `$now`. |
| `{{row.<column>}}` | A column of the `row` (needs `row`); also `id`, `created_at`, `updated_at` and `created_by`. |

Text goes in as is; numbers and booleans as JSON (`3`, `true`); `json` values as JSON text; null, a missing value, or a column the caller may not read (a column read rule) as an empty string. A rendered prompt longer than 100,000 characters fails the call.

The `id` of `row` and `saveTo` uses the step placeholders (`"$params.note"`, `"$user.id"`), not `{{ }}`: the braces belong to the prompt only.

## Waited for, or in the background

The order is always the same: the `allow` rule, the params, the [limits](https://dabadata.com/docs/ai/index.md#limits), the steps (one transaction, committed), then the model call. A refused caller, bad params, a limit or a failed step stops it before the model is called, and nothing is written.

- **Without `saveTo`** the request waits for the model, at most 120 seconds, and the response carries the answer: `ai.status` is `"done"` with `text`, or `"failed"` with `error`.
- **With `saveTo`** the request answers at once with `ai: { status: "queued", id }` and the call runs in the background. When it ends the server writes the row with the server's rights, as a step would: one `update`, traced with `values._action`, sent live to subscribers. On success `column` gets the text and `status` gets `"done"`; on failure only `status` gets `"failed"`, and the column keeps what it had. A row deleted in the meantime fails the call (`row <id> of notes is gone`).

Give the status column a default (`"pending"`) or set it in a step, so the page can show progress. Why a call failed is in its [trace row](https://dabadata.com/docs/ai/index.md#trace), not in the table. A call that never finishes - the server restarted while the model was answering - is marked failed after 10 minutes by a sweep that runs every 5 minutes, and its `status` column gets `"failed"` too (`interrupted (the call did not finish, e.g. the server restarted)`).

## Responses

`POST /v1/actions/<name>` answers as for any action (`ok`, `results` - one per step -, `traceId`, `traceIds`) plus `ai`; `db.action` resolves with the same object. Waited for, done:

```json
{ "ok": true, "results": [], "traceId": 931, "traceIds": [],
  "ai": { "status": "done", "text": "Bonjour", "stopReason": "end",
          "usage": { "inputTokens": 24, "outputTokens": 3 }, "traceId": 932 } }
```

`stopReason` is `"end"` (the model finished), `"max_tokens"` (the text was cut at `maxTokens`) or the provider's own value. `usage` counts may be null when the provider does not report them. `ai.traceId` is the call's own [trace row](https://dabadata.com/docs/ai/index.md#trace). With [web search](https://dabadata.com/docs/ai/index.md#web-search) the answer also has `sources` (`[{ url, title }]`, unique, at most 20) and `usage.webSearches`.

Waited for, failed. The steps committed, so the answer is still HTTP 200:

```json
{ "ok": true, "results": [ { "row": { "id": "5c1d...", ... } } ], "traceId": 933, "traceIds": [ 934 ],
  "ai": { "status": "failed", "error": "the provider failed (status 529)", "traceId": 936 } }
```

In the background (with `saveTo`):

```json
{ "ok": true, "results": [ { "row": { "id": "5c1d...", ... } } ], "traceId": 940, "traceIds": [ 941 ],
  "ai": { "status": "queued", "id": "17" } }
```

| Status | Meaning |
| --- | --- |
| `502` | An action with **no steps** whose call failed (nothing was committed, so there is nothing to report as a success): `{ "error": "ai_failed", "message", "ai": { "status": "failed", "error", "traceId" }, "traceId" }`; `message` is the same text as `ai.error`. |
| `429` | `{ "error": "rate_limited", "message", "retryAfter", "traceId" }`: over the action's `perUser` or `total` limit, e.g. `the action summarize reached its model call limit (20/hour per caller, 500/hour in all); try again in 1260 s`. Or `{ "error": "ai_busy", "message", "retryAfter": 1, "traceId" }`: 10 model calls are already running in this environment. Both send a `Retry-After` header (seconds), and in both nothing ran: no step, no model call. |
| `400` / `403` / `404` / `409` | As for [every action](https://dabadata.com/docs/api/index.md#actions): params, the `allow` rule, a missing row, a failed step. The model is not called. |

In the client, a 502 or 429 rejects with a `DabadataError`; a call that failed after its steps resolves with `ai.status === "failed"`:

```js
try {
  const { ai } = await db.action("translate", { text, lang: "French" });
  if (ai.status === "done") show(ai.text);
  else if (ai.status === "failed") showError(ai.error);
} catch (e) {
  if (e.code === "rate_limited" || e.code === "ai_busy") retryIn(e.body.retryAfter);
  else if (e.code === "ai_failed") showError(e.body.message);
  else throw e;
}
```

On the [MCP server](https://dabadata.com/docs/mcp/index.md) the action's tool says that it calls a model and returns `ai` the same way.

## Limits

- `perUser` (default `"30/hour"`) counts per action and caller: by user id, and for anonymous callers by IP address. `total` (default `"1000/hour"`) counts per action and environment, all callers together.
- A call is counted when it is accepted, before the steps run, so a call whose `check` step then fails still counts. A refused call counts nothing.
- Over either limit: 429 `rate_limited` with `retryAfter` and a `Retry-After` header; nothing runs.
- At most **10 model calls are in flight** per environment at once (waited-for and background calls together; a server constant). The 11th gets 429 `ai_busy` with `retryAfter: 1`, and nothing runs.
- The provider has limits of its own on your key; when it refuses for that reason the call fails with `the provider is rate limiting this key (status 429)`.

## Error texts

`ai.error` (and the 502 `message`, and the trace row's `error`) names the cause in words. It never holds the key or the provider's raw answer:

| Text | When |
| --- | --- |
| `secret X is not set` / `environment variable X is not set` | No value under that name (never stored, or deleted). |
| `the value of secret X is not a valid API key` | The value has a space, a newline or a non-ASCII character, or is longer than 4,096 characters. Nothing was sent. |
| `the provider refused the key in secret X (status 401)` | The provider does not accept the key (401 or 403): wrong, revoked, or for the other provider. |
| `the provider is rate limiting this key (status 429)` | The provider's own rate or spending limit on your account. |
| `the provider refused the request (status 400): <its message>` | Any other 4xx, for example an unknown model. The provider's message is cut to 300 characters, with the key and anything shaped like a key removed. |
| `the provider failed (status 5xx)` | The provider had a problem (500, 529 overloaded, ...). |
| `no answer from the provider within 120 s` | The time limit. |
| `could not reach the provider` | A network error, or a redirect (never followed). |
| `the model declined to answer` | The model refused (Anthropic's `refusal`, OpenAI's `refusal` or content filter, a Gemini prompt block or a `SAFETY`, `PROHIBITED_CONTENT`, `BLOCKLIST`, `SPII` or `RECITATION` finish). |
| `the provider's answer was not understood` | A 2xx answer in an unexpected shape. |
| `row <id> of <table> is not readable by the caller` | The `row` is missing or its read rule refuses the caller. Nothing was sent. |
| `the prompt is <n> characters (at most 100000)` | The rendered prompt is too long. Nothing was sent. |
| `row <id> of <table> is gone` | `saveTo`'s row was deleted while the model answered. |
| `interrupted (the call did not finish, e.g. the server restarted)` | Set by the sweep on a background call left unfinished for 10 minutes. |

## Trace

Each call is one [trace](https://dabadata.com/docs/trace/index.md) row with `op: "ai"`, recorded when the call ends:

```json
{ "op": "ai", "user": { "id": "usr_...", "role": "member" }, "table": "notes", "rowId": "5c1d...",
  "values": { "action": "summarize", "provider": "anthropic", "model": "claude-haiku-5-5", "id": "17",
              "inputTokens": 61, "outputTokens": 22, "stopReason": "end", "_tx": "..." },
  "result": "ok", "error": null, "durationMs": 1840 }
```

With web search, `values` also has `webSearches` (searches run) and `sources` (how many pages the answer cites).

- The caller is the user; `table` and `rowId` are `saveTo`'s (else `""` and null); `values.id` is the call id that `ai.status: "queued"` returned; `result` is `ok` or `error` with the error text.
- The rendered prompt and the answer are never traced: the answer is in the row or the response. The action's own row (`op: "action"`) keeps its params, as for every action.
- For the summarize example the trace shows, for the note: the `check` step, the `read` of `row` as the caller, the `update` that saved the answer (`values._action: "summarize"`), then the `ai` row.

The server also keeps one row per call in its own `ai_calls` table (status `queued` / `running` / `done` / `failed`, provider, model, target, token counts, error, trace id; never the prompt or the answer), deleted after 90 days.

## Security

> **Security:** The `saveTo` write and the steps run with the server's rights: the caller's update rule is not checked for them. When `saveTo.id` (or a step's id) comes from params, add a `check` step that the row is the caller's (`"expect": { "author": "$user.id" }`), as in the [example](https://dabadata.com/docs/ai/index.md#example). Otherwise anyone the `allow` rule lets in can have the answer written into anyone's row.

- **The key is stored encrypted** (AES-256-GCM, with a fresh IV per value, bound to the environment and the name) and **never returned**: no response, trace row, log line, export, schema version or error text holds it. `GET /v1/schema` and the definition show the secret's name only.
- **It is sent only to the provider's fixed https host** (`api.anthropic.com`, `api.openai.com` or `generativelanguage.googleapis.com`), in its auth header. There is no base URL to set, and a redirect is an error, so the key is never sent on.
- **It is cut out of error texts**: a provider message that quotes the key, or anything shaped like a key, is cleaned before it is stored or returned.
- **The system prompt takes no placeholders**, so nothing a caller sends reaches it.
- **The client cannot change the model, the prompts or the budget**: it sends only params, which are type-checked as declared. `maxTokens` and the [limits](https://dabadata.com/docs/ai/index.md#limits) are fixed in the definition.
- **The row is read as the caller**: `row` goes through the read rule and column read rules, so a prompt never contains data the caller could not read themselves.
- What a caller does send (a param, a row they wrote) can still try to steer the model ("ignore the instructions above..."). It cannot reach the key, other rows or the system prompt, but treat the answer as untrusted text: show it with `textContent`, not as HTML.

More on the secrets themselves: [Projects and keys: project secrets](https://dabadata.com/docs/projects/index.md#secrets).
