# Send a page from a Mastra agent

In this guide you build a [Mastra](https://mastra.ai) 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/core` 1.78, `@mastra/memory` 1.38 and `@mastra/libsql` 1.26, with the `pagesystems` package 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](/waitlist), and once invited, sign up and copy the `development` key shown in the [dashboard](/dashboard/keys). Create another there with "Create API key"; it starts with `ap_live_` and is shown once. Never commit it.
- A public https URL for the callback. Page Systems calls it from the internet, so `localhost` does 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:

```text
mastra-pages/
  .env
  agent.ts
  server.ts
  package.json
```

## 1. Create the project and install

```bash
mkdir mastra-pages && cd mastra-pages
npm init -y
npm pkg set type=module
npm install pagesystems @mastra/core @mastra/memory @mastra/libsql
```

The 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.

```bash
OPENAI_API_KEY=sk-...
PAGE_SYSTEMS_API_KEY=ap_live_...
PAGE_SYSTEMS_WEBHOOK_SECRET=whsec_...
CALLBACK_URL=https://your-app.example.com/api/page-systems
```

`PAGE_SYSTEMS_WEBHOOK_SECRET` is the signing secret from the [dashboard](/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](https://mastra.ai/docs/memory/overview) 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.

```ts
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.

```ts
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:

```bash
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:

```bash
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:

```bash
cloudflared tunnel --url http://localhost:3000
```

Copy 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](/docs/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](/docs/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 `instructions` line "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`. Run `env -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 if `PAGE_SYSTEMS_WEBHOOK_SECRET` is not set.

## Next steps

- Browse the [page types](/docs) your agent can send.
- Read [Testing locally](/docs/testing-locally) to try callbacks without a public URL.
- Read [Handling answers](/docs/handling-answers) before you store or log an answer.
- Run the finished example from [GitHub](https://github.com/dennisonbertram/pagesystems/tree/main/examples/guides/mastra).
