AI agents by framework / JavaScript and TypeScript

How to add an AI agent to an Astro site

An AI agent in an Astro site is an endpoint rendered on demand by a server adapter that calls a model with tools, runs the tool calls the model makes against your own data layer as the signed-in user, and streams the result to a client island. The AI SDK covers the model loop and the chat UI; you supply the tools, authorization and limits. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in an Astro app is made of

  • A server endpoint: a file such as src/pages/api/chat.ts that exports a POST handler, rendered on demand by a server adapter. It holds the model API key and calls the model. A fully static build cannot host it.
  • Tools: typed functions the model may call, each wrapping an operation your app already has (a database query, a service call, an internal API).
  • The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. Cap the number of steps.
  • A chat UI: an interactive island, a component from a UI framework such as React, Vue or Svelte hydrated with a client:* directive, because an .astro component ships no client state.
  • Authorization and approval: middleware puts the signed-in user on locals, tools act as that user, and actions that change data wait for the user to approve them.

Ways to add an agent to Astro

ApproachGood forWhat you take on
AI SDK in an on-demand endpointA custom agent with your own tools, model provider and an island for the chat UI.An adapter, tool definitions, authorization in every tool, approval UI, rate and cost limits.
An agent framework (for example Mastra or LangGraph.js)Long-running or multi-agent workflows with memory and orchestration.Another layer to learn and deploy, plus everything in the row above.
Hosted answer-only chat widgetQuestions about your docs or help center.Keeping its content current. It does not act in your app.
Embedded in-app agent (Bunkhouse)Letting signed-in users get tasks done through the pages and API you already have.A script tag, an identity endpoint and hints for operations discovery cannot see.

Build an agent with the AI SDK

The AI SDK is Vercel's TypeScript library for model calls, tool calling and chat UIs, with bindings for React, Vue and Svelte. The examples below use AI SDK 7 with Astro 7, the Node adapter and a React island. Install them with Zod for tool input schemas:

Terminal
npx astro add node react && npm install ai @ai-sdk/react zod

1. Configure the adapter and secrets

The adapter lets selected routes render on demand while the rest of the site stays static. The env.schema entries declare the model key and the approval secret as server secrets for astro:env: they are read from the environment at runtime and cannot be imported from client code.

astro.config.mjs
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  integrations: [react()],
  env: {
    schema: {
      AI_GATEWAY_API_KEY: envField.string({ context: "server", access: "secret" }),
      TOOL_APPROVAL_SECRET: envField.string({ context: "server", access: "secret" }),
    },
  },
});

2. The endpoint

export const prerender = false makes this endpoint run on every request. It reads the user your middleware put on locals and answers 401 without one. Each tool closes over that user, so the model can choose an order ID but never whose orders it reads or cancels. The condition isStepCount(5) lets the model call tools and continue for up to five steps. The setting toolApproval makes cancelOrder wait for the user, and experimental_toolApprovalSecret signs approval requests so a client cannot forge one in the message history it sends back. The handler returns the streaming Response directly.

src/pages/api/chat.ts
import type { APIRoute } from "astro";
import { AI_GATEWAY_API_KEY, TOOL_APPROVAL_SECRET } from "astro:env/server";
import {
  convertToModelMessages,
  createGateway,
  createUIMessageStreamResponse,
  isStepCount,
  streamText,
  toUIMessageStream,
  tool,
  type UIMessage,
} from "ai";
import { z } from "zod";
import { cancelOrder, listOrders } from "../../lib/orders"; // your existing data layer

export const prerender = false; // render on demand; not needed with output: "server"

const gateway = createGateway({ apiKey: AI_GATEWAY_API_KEY });

export const POST: APIRoute = async ({ request, locals }) => {
  const user = locals.user; // set by your middleware
  if (!user) return new Response("Unauthorized", { status: 401 });

  const { messages }: { messages: UIMessage[] } = await request.json();

  const result = streamText({
    model: gateway("anthropic/claude-sonnet-5.5"),
    instructions: "You help the signed-in user manage their orders. Use the tools; never guess order data.",
    messages: await convertToModelMessages(messages),
    tools: {
      listOrders: tool({
        description: "List the signed-in user's recent orders",
        inputSchema: z.object({ status: z.enum(["open", "shipped", "cancelled"]).optional() }),
        execute: async ({ status }) => listOrders(user.id, { status }),
      }),
      cancelOrder: tool({
        description: "Cancel one of the signed-in user's open orders",
        inputSchema: z.object({ orderId: z.string() }),
        execute: async ({ orderId }) => cancelOrder(user.id, orderId),
      }),
    },
    toolApproval: { cancelOrder: "user-approval" },
    experimental_toolApprovalSecret: TOOL_APPROVAL_SECRET,
    stopWhen: isStepCount(5),
    abortSignal: request.signal,
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
};

3. The chat island

The useChat hook posts to /api/chat by default and streams the reply into its state. When the model calls cancelOrder, the tool part arrives in the state approval-requested. The user's answer is sent with addToolApprovalResponse, and the option sendAutomaticallyWhen resumes the conversation once every pending approval has a response. The page hydrates the component with client:load; the page itself can stay static.

src/components/AgentChat.tsx
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { useState } from "react";

export default function AgentChat() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, addToolApprovalResponse } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  });

  return (
    <div>
      {messages.flatMap((message) =>
        message.parts.map((part, i) => {
          const key = `${message.id}-${i}`;
          if (part.type === "text") return <p key={key}>{part.text}</p>;
          if (part.type === "tool-cancelOrder" && part.state === "approval-requested" && !part.approval.isAutomatic) {
            const { id } = part.approval;
            return (
              <p key={key}>
                Cancel order {JSON.stringify(part.input)}?{" "}
                <button onClick={() => addToolApprovalResponse({ id, approved: true })}>Approve</button>
                <button onClick={() => addToolApprovalResponse({ id, approved: false })}>Deny</button>
              </p>
            );
          }
          return null;
        }),
      )}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput("");
        }}
      >
        <input aria-label="Message" value={input} onChange={(e) => setInput(e.currentTarget.value)} />
      </form>
    </div>
  );
}
src/pages/agent.astro
---
import Layout from "../layouts/Layout.astro";
import AgentChat from "../components/AgentChat";
---

<Layout title="Assistant">
  <AgentChat client:load />
</Layout>

Production checklist for Astro

  • Authorize in the endpoint and in every tool. Resolve the session once in middleware, check locals.user in the endpoint, and scope every query to that user. Hiding the island from signed-out visitors is not authorization.
  • Keep the origin check on. Astro's security.checkOrigin rejects cross-site POSTs with form content types (including text/plain) to on-demand routes, and a cross-site application/json POST needs a CORS preflight the endpoint does not grant. Use SameSite session cookies as well.
  • Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments with the schema and your own business rules, and require approval for anything irreversible.
  • Limit cost. Cap steps, rate-limit the endpoint per user in middleware and set spending limits with your model provider.
  • Allow for long responses. The standalone Node adapter streams for as long as the connection stays open; on serverless adapters, raise the function's maximum duration and check that any proxy does not buffer the response.
  • Keep secrets server-side. Read the model key and approval secret from astro:env/server as secrets. Never give them a PUBLIC_ prefix or read them in a component that runs on the client.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Astro app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.

The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Astro backend keeps enforcing its own authorization on every request.

In Astro, render the loader in your layout's <head> with is:inline, so Astro leaves it as a classic external script in place and it runs before your bundled scripts. For signed-in users, add an on-demand src/pages/api/agent-identity.ts endpoint that signs a short-lived token for the user on locals; the signing secret stays in a server environment variable.

src/layouts/Layout.astro (excerpt)
<!-- src/layouts/Layout.astro, in <head> -->
<script is:inline src="/agent-identity.js"><\/script>
<script is:inline src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>

The tested Astro integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.

When Bunkhouse fits, and when to build it yourself

  • Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
  • Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
  • Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
  • Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.

Try it on your Astro app first

A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Astro app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Astro page at http://localhost:4321.

Use a page with disposable data: everything the assistant reads is sent to your agent.

The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.