Grok Bot Developer Guide

Technology: grok-bot · Category: ai · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/grok-bot

Insight:

A Grok bot is not an API call — it is a stateful loop wrapped around a stateless model. The API reference (see xai/) gives you one turn; a bot has to own the other four jobs: persist the transcript, hold a persona steady, execute tools the model asks for, and deliver a reply into a chat surface that has its own rules (WhatsApp's 24-hour window, Telegram's 4096-char cap). The trade-off worth naming up front: Grok's real-time Live Search and 1M/500k context make it the strongest grounded bot brain, but reasoning latency and per-conversation token growth are the two things that will actually break your build — so cap history, cap steps, meter cost per conversation, and keep a Claude fallback wired behind the same interface.

 ██████╗ ██████╗  ██████╗ ██╗  ██╗    ██████╗  ██████╗ ████████╗
██╔════╝ ██╔══██╗██╔═══██╗██║ ██╔╝    ██╔══██╗██╔═══██╗╚══██╔══╝
██║  ███╗██████╔╝██║   ██║█████╔╝     ██████╔╝██║   ██║   ██║
██║   ██║██╔══██╗██║   ██║██╔═██╗     ██╔══██╗██║   ██║   ██║
╚██████╔╝██║  ██║╚██████╔╝██║  ██╗    ██████╔╝╚██████╔╝   ██║
 ╚═════╝ ╚═╝  ╚═╝ ╚═════╝ ╚═╝  ╚═╝    ╚═════╝  ╚═════╝    ╚═╝

Grok Bot Developer Guide

Focus: building a bot on Grok — the webhook loop, conversation state, persona design, tool calling, streaming into a chat surface, cost guardrails, and a Grok→Claude fallback. For the raw API surface (model IDs, Live Search, SDK setup, image generation) see xai/CLAUDE_CODE_INTEGRATION.md — this guide deliberately does not repeat it.

Overview

The xAI API is stateless. Every chat.completions.create call is a fresh mind with no memory of the last one. A bot is the machinery you build around that fact:

Job What it means Where it lives
Ingress Receive an inbound message and prove it is real Webhook route + signature verification
Identity Map a phone number / chat ID to a conversation row Your database
State Rebuild the transcript the model needs, and only that History window + summariser
Persona Keep tone, scope, and refusals stable across turns System prompt (instructions)
Action Let the model call your business functions Tool loop
Egress Deliver the reply within the surface's rules Send API + chunking
Economics Know what each conversation cost you Usage logging per turn

Only the middle box is Grok. Six of the seven jobs are yours.

flowchart LR
  A["User in<br/>WhatsApp · Telegram · Discord"] -->|"inbound webhook"| B["Verify signature<br/>+ ACK 200 fast"]
  B --> C["Load conversation<br/>by chat_id"]
  C --> D["Build messages:<br/>persona + summary<br/>+ last N turns"]
  D --> E["Grok<br/>grok-4.6"]
  E -->|"tool_calls"| F["Execute tools<br/>order lookup · M-Pesa STK<br/>delivery quote"]
  F --> E
  E -->|"final text"| G["Chunk + send<br/>via surface API"]
  E -.->|"on 429 / 5xx / timeout"| H["Claude fallback<br/>same tool schema"]
  H --> G
  G --> I["Persist turn<br/>+ log tokens & cost"]
  I --> C

Which surface?

Surface Transport Streaming to user? The rule that will bite you
WhatsApp Cloud API REST over Graph API, pure HTTP (no SDK) No — one message per send The 24-hour window: outside it, only pre-approved templates go through
Telegram Bot API, grammy Simulated via editMessageText 4096-char message cap; 429 with retry_after
Discord Gateway + REST, discord.js Simulated via message edits 3s interaction ACK deadline — deferReply() or it fails

Cross-references in this repo: whatsapp-business-api/ for the messaging surface itself, ai-agents/ for the general agent loop across providers, together-ai/ for the cheap open-model tier that can serve as a second fallback.

Official Documentation

Resource URL
xAI quickstart https://docs.x.ai/developers/quickstart
Function calling https://docs.x.ai/developers/tools/function-calling
Models & pricing https://docs.x.ai/developers/models
Streaming https://docs.x.ai/developers/model-capabilities/text/streaming
Rate limits https://docs.x.ai/developers/rate-limits
AI SDK xAI provider https://ai-sdk.dev/providers/ai-sdk-providers/xai
AI SDK tools & tool calling https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling
WhatsApp Cloud API https://developers.facebook.com/docs/whatsapp/cloud-api
WhatsApp webhooks https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks
Telegram Bot API https://core.telegram.org/bots/api

Setup

Install

npm install @ai-sdk/xai ai zod @ai-sdk/anthropic

@ai-sdk/xai@4 and @ai-sdk/anthropic@4 both build on @ai-sdk/provider@4, which is what ai@7 ships — upgrade the three together or the provider types drift.

Add the messaging client for your surface — WhatsApp Cloud API needs none (plain fetch against the Graph API):

npm install grammy        # Telegram
npm install discord.js    # Discord
npm install openai        # optional: OpenAI-compatible path to api.x.ai/v1

Environment variables

# Grok — server-side only, never NEXT_PUBLIC_*
XAI_API_KEY=xai-...

# Fallback provider (codeAmani AI routing policy)
ANTHROPIC_API_KEY=sk-ant-...

# WhatsApp Cloud API
WHATSAPP_ACCESS_TOKEN=<system user token>
WHATSAPP_PHONE_NUMBER_ID=<from Meta app dashboard>
WHATSAPP_VERIFY_TOKEN=<random string you invent, echoed on GET>
WHATSAPP_APP_SECRET=<for X-Hub-Signature-256 verification>

# Telegram
TELEGRAM_BOT_TOKEN=<from @BotFather>
TELEGRAM_WEBHOOK_SECRET=<random; sent as X-Telegram-Bot-Api-Secret-Token>

# Conversation store
DATABASE_URL=<postgres connection string>

The model

Use a pinned ID, not an alias. As of 2026-08-23 the flagship is grok-4.6 (500k context). grok-4.3 (1M context) is the cheaper long-context option and is a reasonable bot default when transcripts get long. Check https://docs.x.ai/developers/models before hardcoding — the lineup rotates.

// lib/bot/model.ts
export const GROK_MODEL = "grok-4.6" as const;
export const GROK_FALLBACK_MODEL = "grok-4.3" as const;

1. The bot loop

The shape is always the same, whatever the surface. Acknowledge the webhook immediately, then do the work. Meta retries any webhook you do not 200 within seconds, and a retried webhook means a duplicate reply to the user.

// app/api/whatsapp/webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";
import { handleTurn } from "@/lib/bot/turn";

// Meta's verification handshake — runs once, when you register the callback URL
export async function GET(req: Request) {
  const url = new URL(req.url);
  const mode = url.searchParams.get("hub.mode");
  const token = url.searchParams.get("hub.verify_token");
  const challenge = url.searchParams.get("hub.challenge");

  if (mode === "subscribe" && token === process.env.WHATSAPP_VERIFY_TOKEN) {
    return new Response(challenge, { status: 200 });
  }
  return new Response("forbidden", { status: 403 });
}

export async function POST(req: Request) {
  // Signature is computed over the RAW body — read text, never req.json() first
  const raw = await req.text();
  if (!verifySignature(raw, req.headers.get("x-hub-signature-256"))) {
    return new Response("invalid signature", { status: 401 });
  }

  const payload = JSON.parse(raw);
  const msg = payload.entry?.[0]?.changes?.[0]?.value?.messages?.[0];

  // ACK first; run the model after the response is sent.
  if (msg?.type === "text") {
    after(() => handleTurn({ chatId: msg.from, text: msg.text.body, messageId: msg.id }));
  }
  return new Response("ok", { status: 200 });
}

function verifySignature(raw: string, header: string | null) {
  if (!header?.startsWith("sha256=")) return false;
  const expected = createHmac("sha256", process.env.WHATSAPP_APP_SECRET as string)
    .update(raw)
    .digest("hex");
  const a = Buffer.from(header.slice(7), "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

On a platform without after() (or for work longer than the function timeout), push the turn onto a durable queue instead and let a worker run it. Webhook handlers are the wrong place to wait on a reasoning model.

Idempotency. WhatsApp and Telegram both redeliver. Store the inbound provider message ID with a unique constraint and drop duplicates before you spend a token:

const inserted = await db
  .insertInto("bot_messages")
  .values({ chat_id: chatId, provider_message_id: messageId, role: "user", content: text })
  .onConflict((oc) => oc.column("provider_message_id").doNothing())
  .executeTakeFirst();

if (Number(inserted?.numInsertedOrUpdatedRows ?? 0) === 0) return; // already handled

2. Conversation state

The model is stateless; the transcript is a table. The naive version — append every turn forever and resend it — works for a week and then bills you for a 200k-token prompt on every "asante".

create table bot_conversations (
  id           uuid primary key default gen_random_uuid(),
  chat_id      text not null unique,      -- 254712345678, or Telegram chat.id
  surface      text not null,             -- 'whatsapp' | 'telegram' | 'discord'
  summary      text,                      -- rolling compression of older turns
  locale       text not null default 'en',
  last_user_at timestamptz,               -- drives the WhatsApp 24h window check
  created_at   timestamptz not null default now()
);

create table bot_messages (
  id                  bigserial primary key,
  conversation_id     uuid not null references bot_conversations(id) on delete cascade,
  role                text not null,      -- 'user' | 'assistant' | 'tool'
  content             jsonb not null,
  provider_message_id text unique,        -- idempotency key
  prompt_tokens       int,
  completion_tokens   int,
  cached_tokens       int,
  cost_usd            numeric(12,6),
  created_at          timestamptz not null default now()
);

create index on bot_messages (conversation_id, created_at desc);

The window + summary pattern

Keep the last N turns verbatim; compress everything older into one paragraph the persona can read.

// lib/bot/history.ts
import type { ModelMessage } from "ai";

const VERBATIM_TURNS = 12;

export async function buildMessages(conversationId: string): Promise<ModelMessage[]> {
  const convo = await getConversation(conversationId);
  const recent = await getRecentMessages(conversationId, VERBATIM_TURNS);

  const messages: ModelMessage[] = [];
  if (convo.summary) {
    messages.push({
      role: "user",
      content: `[Earlier in this conversation]\n${convo.summary}`,
    });
  }
  for (const m of recent) {
    messages.push({ role: m.role, content: m.content } as ModelMessage);
  }
  return messages;
}

Re-summarise on a threshold, not on every turn — a summary call is a full model call:

export async function maybeCompress(conversationId: string) {
  const count = await countMessagesSinceSummary(conversationId);
  if (count < 24) return;

  const { text } = await generateText({
    model: xai(GROK_FALLBACK_MODEL), // cheap long-context model does compression fine
    instructions:
      "Compress this customer conversation into under 150 words. Preserve: names, " +
      "order numbers, amounts, phone numbers, delivery addresses, and any unresolved " +
      "request. Drop pleasantries. Write in the third person.",
    messages: await getAllMessagesSinceSummary(conversationId),
  });

  await saveSummary(conversationId, text);
}

Prompt caching pays for this shape. xAI routes requests carrying the same x-grok-conv-id header to the same server, which maximises prefix-cache hits — and a bot's prompt is mostly a stable prefix (persona + summary) with a short tail. Send your conversation ID as that header and watch usage.prompt_tokens_details.cached_tokens climb. Details in xai/CLAUDE_CODE_INTEGRATION.md.


3. Persona design

The system prompt is the only thing standing between "helpful shop assistant" and "Grok being Grok at your customer". Treat it as code: version it, test it, and never build it from user input.

// lib/bot/persona.ts
export function buildInstructions(ctx: {
  businessName: string;
  locale: "en" | "sw";
  hoursText: string;
}) {
  return [
    `You are the WhatsApp assistant for ${ctx.businessName}, a shop in Nairobi.`,
    "",
    "## Scope",
    "You handle: product availability, prices in KES, order status, delivery times,",
    "and M-Pesa payment. For anything else, say you'll pass it to a human and stop.",
    "",
    "## Voice",
    "Short. Two or three sentences, then a question or a next step. This is WhatsApp,",
    "not email. No markdown headings, no bullet lists, no emoji unless the customer",
    "used one first.",
    ctx.locale === "sw"
      ? "Reply in the language the customer writes in. Kiswahili and Sheng are both fine; keep numbers and product names in the original."
      : "Reply in English.",
    "",
    "## Hard rules",
    "- Never invent a price, stock level, or order status. Call a tool or say you don't know.",
    "- Never state that a payment succeeded. Only confirm what check_payment_status returns.",
    "- Never ask for an M-Pesa PIN. Nobody legitimate ever does.",
    "- Amounts are whole KES. Phone numbers are 254XXXXXXXXX.",
    `- Shop hours: ${ctx.hoursText}. Outside them, say when you reopen.`,
    "",
    "## Escalation",
    "If the customer is angry, asks for a refund, or repeats a question twice,",
    "call handoff_to_human and say a person will reply shortly. Do not keep trying.",
  ].join("\n");
}

Four things that make the difference between a demo and a shift-long bot:

  1. Scope fence before voice. A model that knows what it must not answer degrades gracefully; a model that only knows its tone will confidently answer anything.
  2. "Call a tool or say you don't know." State this explicitly. It is the single highest-leverage line against hallucinated stock levels and invented order numbers.
  3. An escalation tool. Without one, the model's only options are to keep improvising or to refuse. Give it a third door.
  4. Never interpolate user text into the persona. Customer content belongs in a user message, always. Interpolating it is prompt injection with extra steps.

4. Tool calling from a bot loop

This is where a bot stops being a chat toy. The AI SDK runs the loop for you — the model asks, your execute runs, the result goes back, repeat — bounded by stopWhen.

// lib/bot/tools.ts
import { tool } from "ai";
import { z } from "zod";

export const botTools = {
  check_stock: tool({
    description: "Look up whether a product is in stock and its current price in KES.",
    inputSchema: z.object({
      query: z.string().describe("Product name or SKU as the customer said it"),
    }),
    execute: async ({ query }) => {
      const items = await searchInventory(query);
      return items.map((i) => ({ sku: i.sku, name: i.name, qty: i.qty, price_kes: i.priceKes }));
    },
  }),

  request_mpesa_payment: tool({
    description:
      "Send an M-Pesa STK Push to the customer's phone for a confirmed order. " +
      "Only call this after the customer has explicitly agreed to the total.",
    inputSchema: z.object({
      order_id: z.string().uuid(),
      amount_kes: z.number().int().positive().describe("Whole KES only — Daraja rejects decimals"),
      phone: z.string().regex(/^254\d{9}$/, "Must be 254XXXXXXXXX"),
    }),
    execute: async ({ order_id, amount_kes, phone }) => {
      // Idempotency: one live STK per order. See MPESA_PATTERNS.md.
      const existing = await getLiveCheckout(order_id);
      if (existing) return { status: "already_pending", checkout_request_id: existing.id };

      const res = await stkPush({ order_id, amount: amount_kes, phone });
      await saveCheckoutRequestId(order_id, res.CheckoutRequestID);
      return { status: "prompt_sent", checkout_request_id: res.CheckoutRequestID };
    },
  }),

  check_payment_status: tool({
    description: "Read the recorded status of an M-Pesa checkout. Never guess payment status.",
    inputSchema: z.object({ checkout_request_id: z.string() }),
    execute: async ({ checkout_request_id }) => getCheckoutStatus(checkout_request_id),
  }),

  handoff_to_human: tool({
    description: "Escalate to a human agent. Use for refunds, complaints, or repeated confusion.",
    inputSchema: z.object({ reason: z.string(), urgency: z.enum(["normal", "high"]) }),
    execute: async ({ reason, urgency }) => {
      await openSupportTicket({ reason, urgency });
      return { escalated: true };
    },
  }),
};
// lib/bot/generate.ts
import { xai } from "@ai-sdk/xai";
import { generateText, isStepCount } from "ai";
import { botTools } from "./tools";
import { buildInstructions } from "./persona";
import { GROK_MODEL } from "./model";

export async function runTurn(conversationId: string, ctx: PersonaCtx) {
  const result = await generateText({
    model: xai(GROK_MODEL),
    instructions: buildInstructions(ctx),
    messages: await buildMessages(conversationId),
    tools: botTools,
    // Default is isStepCount(1) — WITHOUT this the loop stops after the first
    // tool call and your user gets an empty reply.
    stopWhen: isStepCount(5),
    temperature: 0.3,
  });

  return {
    text: result.text,
    usage: result.usage,        // v7: totals across ALL steps
    finalUsage: result.finalStep?.usage,
    steps: result.steps.length,
  };
}

Three rules for bot tools

If you would rather own the loop by hand (or you are on the OpenAI-compatible path), the shape xAI documents is:

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.XAI_API_KEY,
  baseURL: "https://api.x.ai/v1",
  timeout: 360_000, // reasoning models think before they answer
});

let messages = [...history];

for (let step = 0; step < 5; step++) {
  const completion = await client.chat.completions.create({
    model: "grok-4.6",
    messages,
    tools: toolSchemas,        // tool_choice: "auto" is the default
  });

  const message = completion.choices[0].message;
  if (!message.tool_calls) break;

  messages.push(message);
  // Parallel tool calls are ON by default — resolve ALL of them before looping.
  for (const tc of message.tool_calls) {
    const result = await runTool(tc.function.name, JSON.parse(tc.function.arguments));
    messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(result) });
  }
}

parallel_tool_calls: false disables multi-call responses if your tools are not safe to run concurrently. With streaming, a function call arrives whole in a single chunk — it is not streamed across deltas.


5. Streaming into a chat surface

Chat surfaces are not terminals. WhatsApp cannot stream at all — one HTTP POST, one bubble. Telegram and Discord "stream" only by editing a message you already sent, and both rate-limit edits.

The pattern that actually works: stream from the model so you can start the clock early and detect stalls, but deliver on sentence boundaries.

// lib/bot/stream-telegram.ts
import { xai } from "@ai-sdk/xai";
import { streamText, isStepCount } from "ai";

export async function streamToTelegram(bot: Bot, chatId: number, opts: TurnOpts) {
  await bot.api.sendChatAction(chatId, "typing"); // clears after ~5s; re-send on long turns

  const result = streamText({
    model: xai(GROK_MODEL),
    instructions: opts.instructions,
    messages: opts.messages,
    tools: botTools,
    stopWhen: isStepCount(5),
  });

  let buffer = "";
  let sent: { message_id: number } | null = null;
  let lastEdit = 0;

  for await (const part of result.stream) {
    if (part.type !== "text-delta") continue;
    buffer += part.text;

    const now = Date.now();
    if (now - lastEdit < 1200) continue;   // Telegram throttles edits; ~1/sec is safe
    lastEdit = now;

    const body = buffer.slice(0, 4096);     // hard cap: 4096 chars per message
    sent = sent
      ? (await bot.api.editMessageText(chatId, sent.message_id, body), sent)
      : await bot.api.sendMessage(chatId, body);
  }

  if (sent && buffer.length) {
    await bot.api.editMessageText(chatId, sent.message_id, buffer.slice(0, 4096));
  }
}

For WhatsApp, drop the edits and send once — but still stream server-side so a stalled generation trips your timeout instead of the platform's:

const { text, usage } = await runTurn(conversationId, ctx);

await fetch(
  `https://graph.facebook.com/v26.0/${process.env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WHATSAPP_ACCESS_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      messaging_product: "whatsapp",
      to: chatId,                                    // 254XXXXXXXXX, no + and no leading 0
      type: "text",
      text: { body: text.slice(0, 4096) },
    }),
  },
);

The 24-hour window is a bot-architecture problem, not a messaging detail. If the last inbound message from this user is older than 24 hours, that free-form text send fails — you must use an approved template instead. So the bot has to check before it generates:

const stale = Date.now() - convo.last_user_at.getTime() > 24 * 60 * 60 * 1000;
if (stale) {
  await sendTemplate(chatId, "conversation_resume", []); // pre-approved, re-opens the window
  return; // don't burn a Grok call on a message you can't deliver
}

Template creation and approval flow: see whatsapp-business-api/CLAUDE_CODE_INTEGRATION.md.


6. Rate limits and cost guardrails

xAI meters two dimensions — requests per second (derived as RPM/60) and tokens per minute — both tiered by cumulative spend. Over the limit you get HTTP 429, and the documented remedy is exponential backoff. For a bot that means a queue, not a retry-in-the-webhook.

// lib/bot/retry.ts
export async function withBackoff<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;
      const status = err?.status ?? err?.statusCode;
      if (status !== 429 && !(status >= 500 && status < 600)) throw err;
      const wait = Math.min(2 ** i * 500, 8000) + Math.random() * 250; // full jitter
      await new Promise((r) => setTimeout(r, wait));
    }
  }
  throw lastErr;
}

Instrument cost per conversation

You cannot manage what you do not meter, and "tokens per request" is the wrong unit for a bot — the unit is cost per conversation, because that is what scales with users.

// lib/bot/cost.ts
// Rates per 1M tokens. Verify against https://docs.x.ai/developers/models before trusting.
// grok-4.6 prices step UP above a ~200k-token prompt threshold — the tier matters for
// long transcripts, which is exactly what a bot accumulates.
const RATES = {
  "grok-4.6": { input: 2.0, cachedInput: 0.5, output: 6.0 },
  "grok-4.3": { input: 1.25, cachedInput: 0.2, output: 2.5 },
} as const;

export function turnCostUsd(model: keyof typeof RATES, u: {
  inputTokens: number; outputTokens: number; cachedInputTokens?: number;
}) {
  const r = RATES[model];
  const cached = u.cachedInputTokens ?? 0;
  const fresh = Math.max(0, u.inputTokens - cached);
  return (fresh * r.input + cached * r.cachedInput + u.outputTokens * r.output) / 1_000_000;
}
await db.insertInto("bot_messages").values({
  conversation_id: conversationId,
  role: "assistant",
  content: text,
  prompt_tokens: usage.inputTokens,
  completion_tokens: usage.outputTokens,
  cached_tokens: usage.cachedInputTokens ?? 0,
  cost_usd: turnCostUsd(GROK_MODEL, usage),
}).execute();

Then the guardrails that keep one user from becoming the whole bill:

Guardrail Implementation Why
Per-conversation budget Sum cost_usd for the conversation; over ceiling → handoff_to_human One looping user can outspend a hundred normal ones
Per-user message rate Token bucket keyed on chat_id in Redis/Upstash Bots get spammed; each spam message is a paid inference
Step cap stopWhen: isStepCount(5) Every step is a full prompt resend
History cap VERBATIM_TURNS + summarisation Prompt cost grows linearly with turns otherwise
Output cap maxOutputTokens sized to the surface (WhatsApp bubbles are small) Output tokens cost ~3× input
Global kill switch Feature flag checked before every generate The only thing that stops a runaway at 2am

In AI SDK v7, result.usage is the total across every step — it is no longer per-call. For just the last step use result.finalStep.usage. Logging the wrong one silently under-reports multi-tool turns.


7. Grok → Claude fallback

codeAmani's routing policy makes Anthropic Claude primary for complex reasoning and code gen; Grok earns its slot for real-time grounding and conversational speed. For a bot, the practical framing is different: Grok is the default brain, and Claude is the thing that keeps the bot answering when xAI 429s, times out, or ships a bad deploy.

Make the fallback a boundary, not a branch scattered through the code — one interface, two adapters, the same tool schemas on both sides.

flowchart TD
  A["Turn"] --> B["Grok · grok-4.6"]
  B -->|"ok"| Z["Reply"]
  B -->|"429 / 5xx / timeout"| C{"Retries<br/>exhausted?"}
  C -->|"no"| B
  C -->|"yes"| D["Claude · same tools"]
  D -->|"ok"| Z
  D -->|"fails too"| E["Canned holding reply<br/>+ handoff_to_human"]
  E --> Z
// lib/bot/brain.ts
import { xai } from "@ai-sdk/xai";
import { anthropic } from "@ai-sdk/anthropic";
import { generateText, isStepCount } from "ai";

type TurnInput = { instructions: string; messages: ModelMessage[] };

async function grokTurn(input: TurnInput) {
  return generateText({
    model: xai(GROK_MODEL),
    ...input,
    tools: botTools,
    stopWhen: isStepCount(5),
  });
}

async function claudeTurn(input: TurnInput) {
  return generateText({
    model: anthropic("claude-sonnet-4-6"), // pin the current ID — see anthropic/CLAUDE_CODE_INTEGRATION.md
    ...input,
    tools: botTools,                        // identical schemas — this is the whole point
    stopWhen: isStepCount(5),
  });
}

export async function think(input: TurnInput) {
  try {
    const r = await withBackoff(() => grokTurn(input));
    return { ...r, provider: "xai" as const };
  } catch (err) {
    logProviderFailure("xai", err);
    try {
      const r = await claudeTurn(input);
      return { ...r, provider: "anthropic" as const };
    } catch (err2) {
      logProviderFailure("anthropic", err2);
      return {
        text: "Sorry — I'm having trouble right now. A colleague will reply shortly.",
        provider: "none" as const,
        usage: { inputTokens: 0, outputTokens: 0 },
      };
    }
  }
}

Three things that make a fallback real rather than decorative:


codeAmani notes

Security

AI routing

Grok is the bot brain when the value is conversational latency plus live grounding — a shop assistant that can answer "what's the fuel price today" or "is that team playing tonight" without you building a scraper (Live Search: see xai/). Claude stays primary for the harder offline work around the bot: writing the tool layer, reviewing prompts, and any multi-step reasoning that runs outside the chat turn. Together AI is the cheap tier for high-volume, low-stakes classification (intent tagging, language detection) where a frontier model is waste.

Kenya-targeted projects


Troubleshooting

Symptom Cause Fix
Bot replies with an empty message after using a tool stopWhen left at the v7 default isStepCount(1) Set stopWhen: isStepCount(5)
User gets the same reply twice Webhook redelivered after a slow/failed ACK 200 immediately, run the turn after; dedupe on provider_message_id
WhatsApp send returns an error on a free-form text Outside the 24-hour window Check last_user_at first; re-open with an approved template
401 on the webhook you just deployed Signature computed over parsed JSON HMAC the raw body string, before JSON.parse
429 from api.x.ai under load RPS/TPM tier limit Exponential backoff + queue; raise tier by spend, or fall back to Claude
Costs climb every day with the same user count Unbounded history Cap verbatim turns, add summarisation, send x-grok-conv-id for cache hits
Reported token usage looks too low on tool turns Read finalStep.usage instead of usage v7 usage is the all-steps total; that is the number you want
Replies take 40s+ and users repeat themselves Reasoning latency Send a typing indicator immediately, stream, and raise SDK timeout (~360s)
Telegram edits stop landing mid-stream Edit rate limit Throttle edits to ~1/sec and cap bodies at 4096 chars
Bot invents an order status Persona lacks an explicit "call a tool or say you don't know" rule Add the rule and make the tool the only source of that fact

Official docs: