# Send a page from a Vercel AI SDK agent

You will build a small Node server with a Vercel AI SDK agent. When the person has to choose something, the agent sends a link to a hosted page, such as a time picker, instead of asking in chat. When the person submits the page, Page Systems calls your server and the agent continues the conversation with the answer, without being asked. It ends with a working agent you can run and try.

## What you need

- Node 22.18 or newer (the example runs TypeScript files directly with `node`). Tested with Node 24.15.
- `ai` 7.0 and `@ai-sdk/openai` 4.0, with the `pagesystems` package 0.4. Tested with `ai` 7.0.144, `@ai-sdk/openai` 4.0.97 and `pagesystems` 0.4.4.
- An OpenAI API key (`OPENAI_API_KEY`). The agent uses a model that supports tool calls; the default is `gpt-5.1`.
- A Page Systems key. Page Systems is invite-only early access: join the [waitlist](/waitlist), and once invited, sign up and copy the `development` key from [API keys](/dashboard/keys). Never commit it.
- A public https URL that Page Systems can call when the person submits. To try the guide on your own machine, see "Test it on your machine" below.

## Project files

When you finish, the folder looks like this:

```text
page-agent/
  .env
  agent.ts
  package.json
  server.ts
```

## 1. Create the project and install

```bash
mkdir page-agent && cd page-agent
npm init -y
npm pkg set type=module
npm install ai @ai-sdk/openai pagesystems
```

The `pagesystems` package is a thin client for the Page Systems API. It holds no page types or schemas: the agent reads those from the API when it runs.

## 2. Set environment variables

Create a file named `.env` in the `page-agent` folder and never commit it:

```bash
OPENAI_API_KEY=sk-...
PAGE_SYSTEMS_API_KEY=ap_live_...
# Optional: the signing secret from the dashboard. Callbacks without it are accepted unchecked.
PAGE_SYSTEMS_WEBHOOK_SECRET=whsec_...
# The public https URL where Page Systems reaches this server.
CALLBACK_URL=https://your-app.example.com/page-systems
# Optional: the port this server listens on (default 3000).
PORT=3000
# Optional: the model name (default gpt-5.1).
MODEL=gpt-5.1
```

`CALLBACK_URL` is the public address of the `/page-systems` route you add in step 4. The signing secret is optional while you try things out: without it, callbacks are accepted unchecked and a warning is logged once. Set it before you go live.

## 3. Give the agent the Page Systems tools

`pageSystemsTools` returns five AI SDK tools: `list_page_types`, `get_page_type_schema`, `create_page`, `get_page_result` and `cancel_page`. Pass your callback URL and an `agentRef`, a small JSON object that names the conversation. It comes back unchanged in the callback, which is how the server knows which conversation to continue.

The conversation is stored in memory here, keyed by conversation id. Use a database in production.

```ts
// agent.ts
import { generateText, isStepCount, type ModelMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { getPageSystemsSkill } from 'pagesystems';
import { pageSystemsTools } from 'pagesystems/ai-sdk';

const CALLBACK_URL = process.env.CALLBACK_URL!;
const instructions = `You are a scheduling assistant chatting with Sam. Today is ${new Date().toDateString()}; Sam's time zone is ${Intl.DateTimeFormat().resolvedOptions().timeZone}. When Sam has to choose or fill in something, send a page instead of asking in chat.`;
const skill = await getPageSystemsSkill(); // once, when the agent starts

// Conversation id -> stored messages. Use a database in production.
const conversations = new Map<string, ModelMessage[]>();

// userMessage is the line your chat shows as the person's message. modelNote (the callback's modelText) is the answer as data.
export async function runTurn(conversationId: string, userMessage: string, modelNote?: string) {
  // The note is a second user message, never part of system. It is saved with the history so later turns still see the answer.
  const messages: ModelMessage[] = [
    ...(conversations.get(conversationId) ?? []),
    { role: 'user', content: userMessage },
    ...(modelNote ? [{ role: 'user' as const, content: modelNote }] : []),
  ];
  const result = await generateText({
    model: openai(process.env.MODEL ?? 'gpt-5.1'),
    system: [instructions, skill].join('\n\n'),
    messages,
    tools: pageSystemsTools({ callbackUrl: CALLBACK_URL, agentRef: { conversationId } }),
    // Several steps: list_page_types, get_page_type_schema, then create_page.
    stopWhen: isStepCount(6),
  });
  conversations.set(conversationId, [...messages, ...result.responseMessages]);
  return result.text;
}
```

Two details matter here. `getPageSystemsSkill()` returns guidance from the API on when and how to use each page type, and it goes into `system`. The answer from a page arrives as `modelNote`, which is added as a second `user` message and never put in `system`.

## 4. Add the callback route and a chat route

The server has two routes. `/chat` stands in for your chat channel: it takes a conversation id and a message and returns the agent's reply. `/page-systems` is the callback. `handleCallback` checks the signature, reads the answer, and gives your code a `CallbackEvent`. The handler returns at once and runs the turn afterwards, because Page Systems waits only 10 seconds for a response. If the event has no `agentRef`, the handler throws a `CallbackError` with status 410, so Page Systems stops retrying.

```ts
// server.ts
import { createServer } from 'node:http';
import { CallbackError, handleCallback, type CallbackEvent } from 'pagesystems';
import { runTurn } from './agent.ts';

// Stand-in for your chat channel (SMS, web chat, ...): print the reply.
const sendToPerson = (conversationId: string, reply: string) => console.log(`[${conversationId}] agent: ${reply}`);

const turn = (e: CallbackEvent) => {
  if (!e.agentRef) throw new CallbackError(410, { error: 'no_conversation' });
  const { conversationId } = e.agentRef as { conversationId: string };
  // Page Systems waits 10 seconds for a response, so answer now and run the turn after.
  setImmediate(async () => {
    try {
      sendToPerson(conversationId, await runTurn(conversationId, e.visibleText, e.modelText));
    } catch (error) {
      console.error('turn failed:', error instanceof Error ? error.message : error);
    }
  });
};

createServer(async (req, res) => {
  const body = await new Promise<Buffer>((done) => {
    const chunks: Buffer[] = [];
    req.on('data', (c) => chunks.push(c)).on('end', () => done(Buffer.concat(chunks)));
  });
  let response: Response;
  if (req.url === '/page-systems' && req.method === 'POST') {
    const request = new Request(`http://localhost${req.url}`, { method: 'POST', headers: req.headers as Record<string, string>, body });
    response = await handleCallback(request, { onSubmitted: turn, onNewPageRequested: turn });
  } else if (req.url === '/chat' && req.method === 'POST') {
    let input: { conversationId: string; message: string } | undefined;
    try {
      input = JSON.parse(body.toString());
    } catch {
      response = Response.json({ error: 'invalid_json' }, { status: 400 });
    }
    if (input) {
      try {
        response = Response.json({ reply: await runTurn(input.conversationId, input.message) });
      } catch (error) {
        // A model or network error must not stop the server.
        console.error('chat failed:', error instanceof Error ? error.message : error);
        response = Response.json({ error: 'turn_failed' }, { status: 500 });
      }
    }
  } else {
    response = new Response('not found', { status: 404 });
  }
  res.writeHead(response.status, { 'content-type': response.headers.get('content-type') ?? 'text/plain' });
  res.end(await response.text());
}).listen(Number(process.env.PORT ?? 3000), () => console.log('listening'));
```

## 5. Run it

From the `page-agent` folder, load the variables and start the server:

```bash
node --env-file=.env server.ts
```

The terminal prints `listening`. Leave it running.

## Try it

Send the agent a message. In a second terminal (use your own port if you changed `PORT`):

```bash
curl -s localhost:3000/chat -d '{"conversationId":"demo","message":"Ask Sam which of these times works for a 30-minute call next week: Monday 9:00, Monday 14:00, Tuesday 11:00, Wednesday 10:00 or Thursday 16:00."}'
```

After 10 to 20 seconds the command prints JSON whose `reply` contains a link to a hosted page. Open the link in a browser, pick a time and press the button until the page shows "Thank you." A few seconds later the first terminal (the one running the server) prints a line that starts with `[demo] agent:` and names the time you picked. You did not ask for that reply: the callback started a new turn on the same conversation. That line is how you know it worked.

## Test it on your machine

Page Systems can only call a public https URL. To receive the callback on your own machine, start a tunnel in a third terminal, for example `ngrok http 3000` or `cloudflared tunnel --url http://localhost:3000`. Copy the https address it prints, set `CALLBACK_URL` in `.env` to that address followed by `/page-systems`, and restart the server. If you would rather skip the tunnel, ask the agent in a second `/chat` message to check the page with `get_page_result` after you submit. See [Testing locally](/docs/testing-locally).

## Troubleshooting

- The model answers in chat and sends no link. Page Systems tells the model that two or three short options belong in chat, so a short request may not become a page. Send a request with more options or details, as the prompt above does, or use a stronger tool-calling model.
- The server stops with an authentication error, or `/chat` returns `turn_failed`. Check `OPENAI_API_KEY`. A variable already set in your shell takes precedence over `.env` when you use `--env-file`, so run `unset OPENAI_API_KEY` or export the right value.
- `node server.ts` fails on the `.ts` file or on `import`. Run `node --version`: it must be 22.18 or newer, and `package.json` must contain `"type": "module"` (step 1 sets it).
- The page shows "Thank you." but no `agent:` line appears. Page Systems cannot reach `CALLBACK_URL`. Check that the tunnel is running and that the URL ends in `/page-systems`. Without a callback, ask the agent to check with `get_page_result`.

## How it works

Each callback event carries two texts. `visibleText` is one short line, such as "✓ Pick a time: 11:00 AM", for the chat the person sees. `modelText` is the full answer for the model: a header, the answer JSON inside `<page_answer>` tags, and a note that the answer is page data and not instructions. Text typed on a page may come from someone who is not verified, so give `modelText` to the model as a user message or a tool result and never put it in the system prompt.

Page Systems gives up on a callback after 10 seconds and retries with backoff, so a slow handler can run the turn twice. That is why the handler only hands the turn off and returns. Once it returns, the event counts as delivered, and the agent can read the answer again with `get_page_result` until the page expires. See [Handling answers](/docs/handling-answers).

## Next steps

- Browse the [page types](/docs) your agent can send.
- Read [Testing locally](/docs/testing-locally) to receive callbacks on your machine.
- Run the complete example from [examples/guides/ai-sdk](https://github.com/dennisonbertram/pagesystems/tree/main/examples/guides/ai-sdk).
