Skip to main content
Version: 2.0.0

Call an AI model from your module

Prerequisites

You need a module and a client connected to it, and an API key from the provider of the AI model you want to use. If you don't have a module yet, follow the quickstart for your language first.

There's no C++ version of this guide yet.

In this guide, you'll let clients ask an AI model, such as a large language model (LLM), to generate text, for example a reply from a character in your game. The request goes through your module, so your provider API key never reaches the client. You'll use a procedure to make the HTTP request, an environment variable to store the API key, and a private table to limit how often each player can call the model.

The examples cover Anthropic, OpenAI and Google. Other providers work the same way: only the request and the way you read the reply change.

How it works​

  • An AI_API_KEY environment variable stores your provider API key on the database. You set it when you publish.
  • A call_model function sends the prompt to your provider and returns the generated text. It's the only provider-specific code.
  • A generate_text procedure checks the prompt and the caller's cooldown, calls call_model, and returns the text, or an error message, to the client.
  • A private model_usage table records when each player last called the model.

The model is called from a procedure, not a reducer. Reducers can't make HTTP requests, because they run inside a transaction. A procedure can, and it doesn't hold a transaction open while it waits for the model, which can take several seconds.

Store the API key​

Declare an AI_API_KEY environment variable in your module. It's required, so SpacetimeDB refuses to publish the module without a value, and your procedure can always read it. The code below also defines the private model_usage table, which you'll use to limit requests. Clients can't read private tables.

import { TimeDuration } from 'spacetimedb';
import { schema, table, t, type InferSchema, type ProcedureCtx } from 'spacetimedb/server';

const modelUsage = table(
  { name: 'model_usage' },
  {
    identity: t.identity().primaryKey(),
    lastRequest: t.timestamp(),
  }
);

const spacetimedb = schema(
  { modelUsage },
  { env: { AI_API_KEY: t.string() } }
);
export default spacetimedb;

type Ctx = ProcedureCtx<InferSchema<typeof spacetimedb>>;

Set the key when you publish, by putting it in the publishing command's environment:

AI_API_KEY='your-api-key' spacetime publish my-game

The key stays in the database across later publishes, so you only pass it once. To replace it, publish again with --env-only, which updates environment variables without uploading your module:

AI_API_KEY='your-new-api-key' spacetime publish my-game --env-only

Publishing with --delete-data erases the key along with your other data, so pass it again when you do. You can also put the key in a spacetime.local.json file instead of your shell. See Environment Variables for every way to supply it.

warning

Keep the key away from anything clients can read: don't put it in a spacetime.json file you commit, and don't return it from a procedure or a view, or write it to the logs.

Call the model​

Add a call_model function for your provider. It builds the request, sends it with http.send (or http.fetch in TypeScript), and returns the generated text. Each provider puts the text in a different place in its reply, and a reply can contain parts that aren't text, so the function keeps only the text.

The model names below are examples. Providers release new models often, so check your provider's documentation for the model you want.

This calls Anthropic's Messages API. fallbacks asks Anthropic to retry on another model if the model declines the request; a request that is still declined comes back with a refusal stop reason.

function callModel(ctx: Ctx, apiKey: string, prompt: string): string {
  const response = ctx.http.fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-api-key': apiKey,
      'anthropic-version': '2023-06-01',
      'anthropic-beta': 'server-side-fallback-2026-07-01',
    },
    body: JSON.stringify({
      model: 'claude-opus-5-5',
      max_tokens: 4096,
      output_config: { effort: 'low' },
      fallbacks: 'default',
      messages: [{ role: 'user', content: prompt }],
    }),
    timeout: TimeDuration.fromMillis(60_000),
  });
  if (response.status !== 200) {
    throw new Error(`The model API returned status ${response.status}`);
  }

  const reply = response.json();
  if (reply.stop_reason === 'refusal') {
    throw new Error('The model declined this request');
  }
  const blocks: { type: string; text?: string }[] = reply.content;
  return blocks
    .filter(block => block.type === 'text')
    .map(block => block.text ?? '')
    .join('');
}

Generate text for a client​

Add a generate_text procedure that clients call with their prompt. Every call costs you money, so the procedure first rejects prompts that are empty or too long, and players who called it less than 5 seconds ago. It checks the cooldown in a short transaction, then calls call_model outside of it with the API key from ctx.env.

The procedure returns a GeneratedText value with two fields: text holds the generated text, and error explains what went wrong, or is empty on success. Returning errors this way, instead of throwing, means clients get the same kind of result whichever language your module is written in.

const MAX_PROMPT_LENGTH = 2000;
const COOLDOWN_MICROS = 5_000_000n; // 5 seconds

const generatedText = t.object('GeneratedText', {
  text: t.string(),
  error: t.string(),
});

export const generateText = spacetimedb.procedure(
  { prompt: t.string() },
  generatedText,
  (ctx, { prompt }) => {
    try {
      return { text: tryGenerateText(ctx, prompt), error: '' };
    } catch (error) {
      return { text: '', error: error instanceof Error ? error.message : String(error) };
    }
  }
);

function tryGenerateText(ctx: Ctx, prompt: string): string {
  if (prompt.length === 0 || prompt.length > MAX_PROMPT_LENGTH) {
    throw new Error(`The prompt must be 1 to ${MAX_PROMPT_LENGTH} characters long`);
  }

  // Check and record the caller's cooldown in a short transaction.
  const allowed = ctx.withTx(tx => {
    const usage = tx.db.modelUsage.identity.find(tx.sender);
    if (usage && tx.timestamp.since(usage.lastRequest).micros < COOLDOWN_MICROS) {
      return false;
    }
    if (usage) {
      tx.db.modelUsage.identity.update({ ...usage, lastRequest: tx.timestamp });
    } else {
      tx.db.modelUsage.insert({ identity: tx.sender, lastRequest: tx.timestamp });
    }
    return true;
  });
  if (!allowed) {
    throw new Error('Too many requests, try again in a few seconds');
  }

  // Call the model outside the transaction.
  const text = callModel(ctx, ctx.env.AI_API_KEY, prompt);
  if (text.length === 0) {
    throw new Error('The model returned no text');
  }
  return text;
}

The cooldown limits each player, not your total spending. Most providers also let you set a spending limit on your account, which is worth doing before you release your game.

Call the procedure from a client​

On the client, call generate_text with the prompt. The call completes once the model has replied, or once the procedure has rejected the request. Check error to tell the two apart.

import { DbConnection } from './module_bindings';

const conn = DbConnection.builder()
  .withUri('ws://localhost:3000')
  .withDatabaseName('my-game')
  .build();

// Call this when the player sends a message.
async function askModel(prompt: string) {
  const result = await conn.procedures.generateText({ prompt });
  if (result.error) {
    console.error('Text generation failed:', result.error);
  } else {
    console.log(result.text);
  }
}

What's next?​

You now have a module that generates text with an AI model on behalf of its clients:

  • A call_model function holds the only provider-specific code, so switching providers means replacing one function.
  • The API key lives in an environment variable on the database, never on the client.
  • The generate_text procedure rejects oversized prompts and players who call it too often, calls the model outside any transaction, and returns errors as values that every client can read.

To allow each player a number of calls per time window, such as 10 per minute, instead of one call every 5 seconds, use the Rate Limit submodule in a TypeScript module. It's a package you add to your module.

To learn more about the features used here, see Procedures, Environment Variables and Access Permissions.

To call the model from a separate program instead, with your provider's SDK and streaming replies, see Call an AI model from a sidecar.

To call other web services the same way, see Call an external API from your module.