dabadataDocs
Concepts / AI calls in actions

Concepts

AI calls in actions

An action 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, 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 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).
  • 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, 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:

KeyWhereMeaning
secretHosted projectsThe name of a project secret (^[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).
secretEnvThe default project of a self-hosted server onlyThe 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, 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 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.

KeyDefaultRules
providerrequired"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).
modelrequiredA 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 / secretEnvrequired, exactly oneThe name of the key; see The API key.
systemnoneThe 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.
promptrequiredThe user message, 1 to 20,000 characters, with {{ }} placeholders. Rendered, at most 100,000 characters.
rownone{ 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, traced as a read); a row the caller may not read, or a missing one, fails the call.
maxTokens1024An int from 1 to 32,000: the most the model may write. An answer cut by it has stopReason: "max_tokens".
saveTonone (the response waits){ table, id, column, status?, sources? }: where the answer goes, and the call then runs in the 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) 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.
toolsnone{ "webSearch": { "maxUses"?, "allowedDomains"? } }: the model may search the web before it answers. See 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:

text
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

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.

KeyDefaultRules
maxUses3An 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.
allowedDomainsany site1..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:

ProviderRequestSources from
AnthropicThe 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
OpenAIThe 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
GeminiGrounding 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 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}}):

PlaceholderValue
{{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, 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, 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. With 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" } }
StatusMeaning
502An 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 / 409As for every action: 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 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:

TextWhen
secret X is not set / environment variable X is not setNo value under that name (never stored, or deleted).
the value of secret X is not a valid API keyThe 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 sThe time limit.
could not reach the providerA network error, or a redirect (never followed).
the model declined to answerThe 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 understoodA 2xx answer in an unexpected shape.
row <id> of <table> is not readable by the callerThe 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 gonesaveTo'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 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

  • 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 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.

dabadata documentation, written against API contract v2.