Guides / TypeScript / Vercel AI SDK
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. ai7.0 and@ai-sdk/openai4.0, with thepagesystemspackage 0.4. Tested withai7.0.144,@ai-sdk/openai4.0.97 andpagesystems0.4.4.- An OpenAI API key (
OPENAI_API_KEY). The agent uses a model that supports tool calls; the default isgpt-5.1. - A Page Systems key. Page Systems is invite-only early access: join the waitlist, and once invited, sign up and copy the
developmentkey from API 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:
page-agent/
.env
agent.ts
package.json
server.ts1. Create the project and install
mkdir page-agent && cd page-agent
npm init -y
npm pkg set type=module
npm install ai @ai-sdk/openai pagesystemsThe 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:
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.1CALLBACK_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.
// 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.
// 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:
node --env-file=.env server.tsThe 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):
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.
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
/chatreturnsturn_failed. CheckOPENAI_API_KEY. A variable already set in your shell takes precedence over.envwhen you use--env-file, so rununset OPENAI_API_KEYor export the right value. node server.tsfails on the.tsfile or onimport. Runnode --version: it must be 22.18 or newer, andpackage.jsonmust contain"type": "module"(step 1 sets it).- The page shows "Thank you." but no
agent:line appears. Page Systems cannot reachCALLBACK_URL. Check that the tunnel is running and that the URL ends in/page-systems. Without a callback, ask the agent to check withget_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.
Next steps
- Browse the page types your agent can send.
- Read Testing locally to receive callbacks on your machine.
- Run the complete example from examples/guides/ai-sdk.