Guides / TypeScript / Mastra
Send a page from a Mastra agent
In this guide you build a Mastra agent that, when it needs an answer, sends the person a link to a small web page 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. You end with a working agent you can run on your machine.
What you need
- Node.js 22 or newer (the example runs TypeScript files directly, which needs Node 22.18 or newer; it was tested on Node 24).
- Mastra:
@mastra/core1.78,@mastra/memory1.38 and@mastra/libsql1.26, with thepagesystemspackage 0.4. - A model key. The example uses OpenAI through
OPENAI_API_KEY; Mastra supports other providers. - A Page Systems key. Page Systems is invite-only early access: request access at /waitlist, and once invited, sign up and copy the
developmentkey shown in the dashboard. Create another there with "Create API key"; it starts withap_live_and is shown once. Never commit it. - A public https URL for the callback. Page Systems calls it from the internet, so
localhostdoes not work. To try the agent on your machine, use a tunnel (see "Test it on your machine" below).
The finished project has these files:
mastra-pages/
.env
agent.ts
server.ts
package.json1. Create the project and install
mkdir mastra-pages && cd mastra-pages
npm init -y
npm pkg set type=module
npm install pagesystems @mastra/core @mastra/memory @mastra/libsqlThe project must be ESM ("type": "module"), because agent.ts uses top-level await.
2. Set environment variables
Create a file named .env in the project folder. Keep it out of version control.
OPENAI_API_KEY=sk-...
PAGE_SYSTEMS_API_KEY=ap_live_...
PAGE_SYSTEMS_WEBHOOK_SECRET=whsec_...
CALLBACK_URL=https://your-app.example.com/api/page-systemsPAGE_SYSTEMS_WEBHOOK_SECRET is the signing secret from the dashboard. The callback handler uses it to check each callback. CALLBACK_URL is the public https URL of the route you add in step 4. For a first run on your machine, use the tunnel URL from "Test it on your machine" and add /api/page-systems to the end.
3. Give the agent the Page Systems tools
Create agent.ts. pageSystemsTools from pagesystems/mastra gives the agent five tools: list page types, get the schema of one, create a page, read its answer and cancel it. The package fetches page types and schemas from the Page Systems API when the agent runs, so you do not copy any schema into your code. getPageSystemsSkill fetches the instructions that tell the model when to use a page.
The agent uses Mastra memory with a thread and a resource, both set to the conversation id. The same id goes into agentRef, which Page Systems returns in the callback so your route knows which conversation to continue.
import { Agent } from '@mastra/core/agent';
import { LibSQLStore } from '@mastra/libsql';
import { Memory } from '@mastra/memory';
import { getPageSystemsSkill } from 'pagesystems';
import { pageSystemsTools } from 'pagesystems/mastra';
const CALLBACK_URL = process.env.CALLBACK_URL!; // public https URL of the callback route in server.ts
const today = new Date().toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' });
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const yourInstructions = `You are a scheduling assistant. Today is ${today} (${timeZone}). When you need an answer from someone, send a page with the Page Systems tools instead of asking in chat, and give the link.`;
const skill = await getPageSystemsSkill(); // once, at startup
const memory = new Memory({ storage: new LibSQLStore({ id: 'mastra', url: 'file:./mastra.db' }) });
// 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) {
const assistant = new Agent({
id: 'assistant',
name: 'Assistant',
instructions: [yourInstructions, skill].join('\n\n'),
model: 'openai/gpt-5.1', // your model
tools: pageSystemsTools({ callbackUrl: CALLBACK_URL, agentRef: { conversationId } }),
memory,
});
const result = await assistant.generate(userMessage, {
memory: { resource: conversationId, thread: conversationId },
maxSteps: 6,
context: modelNote ? [{ role: 'user', content: modelNote }] : undefined, // a user message for this call
});
return result.text;
}Your instructions should include today's date and the person's time zone, because time-based pages need both. maxSteps leaves room for listing page types, reading a schema and creating the page in one turn.
4. Add the callback route and run it
Create server.ts. It has two routes: /chat takes a message from the person, and /api/page-systems is the callback.
import { createServer } from 'node:http';
import { CallbackError, handleCallback, type CallbackEvent } from 'pagesystems';
import { runTurn } from './agent.ts';
// Stand-in for your chat channel: print the reply. Replace it with code that delivers the reply to the person.
const sendToPerson = async (conversationId: string, reply: string) => console.log(`[${conversationId}] agent: ${reply}`);
const server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks);
if (req.method === 'POST' && req.url === '/api/page-systems') {
// Page Systems waits 10 seconds for this response, so answer now and run the turn in the background.
const turn = (e: CallbackEvent) => {
if (!e.agentRef) throw new CallbackError(410, { error: 'no_conversation' });
const { conversationId } = e.agentRef as { conversationId: string };
setImmediate(async () => {
try {
const reply = await runTurn(conversationId, e.visibleText, e.modelText);
await sendToPerson(conversationId, reply);
} catch (error) {
console.error('turn failed:', error instanceof Error ? error.message : error);
}
});
};
const request = new Request('http://localhost/api/page-systems', { method: 'POST', headers: req.headers as Record<string, string>, body });
const response = await handleCallback(request, { onSubmitted: turn, onNewPageRequested: turn });
res.writeHead(response.status, { 'content-type': 'application/json' }).end(await response.text());
} else if (req.method === 'POST' && req.url === '/chat') {
let input: { conversationId: string; message: string };
try {
input = JSON.parse(body.toString());
} catch {
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'invalid_json' }));
return;
}
try {
const { conversationId, message } = input;
const reply = await runTurn(conversationId, message);
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ reply }));
} catch (error) {
console.error('chat failed:', error instanceof Error ? error.message : error);
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'turn_failed' }));
}
} else {
res.writeHead(404).end();
}
});
server.listen(Number(process.env.PORT ?? 3000), () => console.log('listening'));handleCallback reads the answer, checks the signature, and gives your code the event. The handler only hands the turn off and returns, because Page Systems gives up on a request after 10 seconds and retries it. Pages that this app did not create have no agentRef, so the handler declines them with a 410.
Start the server. It prints listening when it is ready:
node --env-file=.env server.ts--env-file does not replace a variable that is already set in your shell. If you already export OPENAI_API_KEY, that value wins over the one in .env.
Try it
Keep the server running. In another terminal, send the agent a message:
curl -s localhost:3000/chat -H 'content-type: application/json' \
-d '{"conversationId":"sam-1","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."}'The reply is JSON with a link to a page, for example {"reply":"... https://pagesystems.ai/p/abc123"}. Open the link, pick a time and press the button until the page shows "Thank you." After a few seconds, the terminal that runs the server prints the agent's next message, for example [sam-1] agent: Sam chose Tuesday at 11:00 AM for the call. That line means it worked: nobody asked the agent to check, because the callback started the new turn.
Test it on your machine
Page Systems must reach your callback route, so start a tunnel to the port the server uses (3000 unless you set PORT) in a third terminal:
cloudflared tunnel --url http://localhost:3000Copy the https:// URL it prints, set CALLBACK_URL in .env to that URL plus /api/page-systems, and restart the server. ngrok http 3000 works the same way. If you do not want a tunnel, leave the callback out and ask the agent in a later message to check the answer with get_page_result. See Testing locally.
How it works
The callback event has two texts. visibleText is one short line such as "✓ Pick a time for a call with Sam: 11:00 AM", which is what a chat would show as the person's message. modelText is the full answer, wrapped in <page_answer> tags with a note that it is page data and not instructions. The answer can contain text typed by whoever opened the link, who is not verified. So the example passes visibleText as the new message and modelText through Mastra's context option, as a user-role message, and never puts it in the instructions.
Because runTurn uses the same thread and resource as the first turn, memory supplies the earlier messages. Mastra's context option gives the model the extra message for that one call; it is not saved to memory. Do not log modelText or the answer. See Handling answers.
Each page accepts one submission and then closes. If a page expires unanswered, the person can press "Request a new page", and onNewPageRequested runs. In this example it starts a new turn so the agent can decide what to do.
Troubleshooting
- The reply has no link and the agent only writes a message or asks a question in chat. Page Systems tells the model to answer short questions (a yes/no, or two or three short options) in chat, and a weaker model may do this even for more options. The
instructionsline "send a page ... instead of asking in chat" helps; without it the model drafted a message to Sam in the test run. Send a request with more options or details, or use a model that is better at calling tools. - The model call fails with "Missing Authentication header" or an invalid key error. A variable already set in your shell overrides
.env. Runenv -u OPENAI_API_KEY node --env-file=.env server.ts, or unset it. - The page shows "Thank you." but the server prints nothing. Page Systems cannot reach
CALLBACK_URL. Check that the tunnel is running, that the URL ends with/api/page-systems, and that you restarted the server after changing.env. The server prints a warning at start ifPAGE_SYSTEMS_WEBHOOK_SECRETis not set.
Next steps
- Browse the page types your agent can send.
- Read Testing locally to try callbacks without a public URL.
- Read Handling answers before you store or log an answer.
- Run the finished example from GitHub.