# Send a page from a Claude Agent SDK agent

You will build a small Node server with a Claude Agent 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 same session 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.
- `@anthropic-ai/claude-agent-sdk` 0.3.303 and `pagesystems` 0.4.4 (both installed in step 1), and an `ANTHROPIC_API_KEY`.
- 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](#test-it-on-your-machine).

When you finish step 4, the project looks like this:

```text
page-agent/
  .env
  package.json
  agent.ts
  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 @anthropic-ai/claude-agent-sdk 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
ANTHROPIC_API_KEY=sk-ant-...
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/api/page-systems
```

## 3. Give the agent the Page Systems tools

`pageSystemsServer` from `pagesystems/claude-agent-sdk` is an in-process MCP server with the Page Systems tools: `list_page_types`, `get_page_type_schema`, `create_page`, `get_page_result` and `cancel_page`. Passed in `mcpServers` under the name `pages`, they appear to the agent as `mcp__pages__*`.

Two things in this code matter. The `agentRef` is any small JSON object that names your conversation. Page Systems returns it unchanged in the callback, so you know which conversation to continue. The SDK gives every run a session id. The code keeps it next to your conversation id and passes it as `resume` on the next turn, which continues the same session.

By default the SDK loads the Claude settings and connectors of the user it runs as, so a server-side agent could try your local tools instead of creating a page. `settingSources: []` and `strictMcpConfig: true` make the agent see only the Page Systems tools.

Create `agent.ts` in the `page-agent` folder:

```ts
import { query } from '@anthropic-ai/claude-agent-sdk';
import { getPageSystemsSkill } from 'pagesystems';
import { pageSystemsServer } from 'pagesystems/claude-agent-sdk';

const CALLBACK_URL = process.env.CALLBACK_URL!; // public https URL of /api/page-systems
const yourInstructions = `You are a scheduling assistant. Today is ${new Date().toDateString()}; the person's time zone is ${Intl.DateTimeFormat().resolvedOptions().timeZone}.`;

const skill = await getPageSystemsSkill(); // once, at startup
const sessionIds = new Map<string, string>(); // conversation id -> SDK session id; use a database in production

// 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) {
  let reply = '';
  for await (const message of query({
    // The note rides in the prompt (a user message), never in the system prompt.
    prompt: [userMessage, modelNote].filter(Boolean).join('\n\n'),
    options: {
      resume: sessionIds.get(conversationId),
      systemPrompt: [yourInstructions, skill].join('\n\n'),
      mcpServers: { pages: pageSystemsServer({ callbackUrl: CALLBACK_URL, agentRef: { conversationId } }) },
      allowedTools: ['mcp__pages__*'],
      settingSources: [], // ignore local Claude settings
      strictMcpConfig: true, // use only the MCP servers above
    },
  })) {
    if (message.type === 'result') {
      sessionIds.set(conversationId, message.session_id);
      if (message.subtype === 'success') reply = message.result;
    }
  }
  return reply;
}
```

The system prompt includes today's date and the person's time zone, because time-based pages need both. `getPageSystemsSkill()` fetches Page Systems' guidance on when to use each page type from the API, so new page types need no package update.

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

Page Systems calls `/api/page-systems` when the person submits. `handleCallback` checks the signature (when `PAGE_SYSTEMS_WEBHOOK_SECRET` is set), reads the answer and gives your handler the event. The handler only starts the turn and returns, because Page Systems waits 10 seconds for a response and retries after that. `/chat` stands in for your own chat. Create `server.ts` in the `page-agent` folder:

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

// Replace with your delivery: a chat message, SMS, and so on.
const sendToPerson = async (conversationId: string, text: string) => console.log(`[to ${conversationId}] ${text}`);

const turn = (e: Parameters<NonNullable<Parameters<typeof handleCallback>[1]['onSubmitted']>>[0]) => {
  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 {
      await 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 chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk);
  const body = Buffer.concat(chunks);
  if (req.url === '/api/page-systems' && req.method === 'POST') {
    const request = new Request(`http://${req.headers.host}${req.url}`, { method: 'POST', headers: req.headers as HeadersInit, 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.url === '/chat' && req.method === 'POST') {
    // Stand-in for your chat: {"conversationId": "c1", "message": "..."}
    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 {
      res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ reply: await runTurn(input.conversationId, input.message) }));
    } 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();
  }
}).listen(Number(process.env.PORT ?? 3000), () => console.log('listening'));
```

## 5. Run it

```bash
npm pkg set scripts.start="node --env-file=.env server.ts"
npm start
```

The terminal prints `listening` and stays open. The server listens on port 3000 (`PORT` changes it). Keep it running and use a second terminal for the next step. Page Systems must be able to reach `CALLBACK_URL`; see [Test it on your machine](#test-it-on-your-machine).

## Try it

In the second terminal, send the agent a message the way your chat would:

```bash
curl -X POST localhost:3000/chat -d '{"conversationId":"c1","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 curl command prints a JSON reply that contains a link to a page. Open the link in a browser, pick a time and press the button until the page says "Thank you." Page Systems then calls `/api/page-systems`, the server runs a new turn in the same session, and after a few seconds the first terminal (the one running `npm start`) prints a line such as `[to c1] Sam chose Tuesday at 11:00 ...`. That line is the agent's new reply and it names the time you picked. You did not ask the agent to continue.

## Test it on your machine

Page Systems cannot call `localhost`. To receive the callback, start a tunnel to port 3000 in a third terminal, for example `ngrok http 3000` or `cloudflared tunnel --url http://localhost:3000`. Set `CALLBACK_URL` in `.env` to the tunnel's https URL followed by `/api/page-systems`, then restart `npm start`. You can also answer the page and then tell the agent in your next `/chat` message that you did, so it checks with `get_page_result`; this does not test the automatic new turn. See [Testing locally](/docs/testing-locally).

## How it works

The callback event has two texts. `visibleText` is one short line for your chat, such as "✓ Pick a time for our call: 11:00 AM". `modelText` is the full answer for the model, inside `<page_answer>` tags with a note that it is data and not instructions. The answer can contain text typed by whoever opened the link, so give `modelText` to the model as a user message and never put it in the system prompt. The Claude Agent SDK has no separate channel for hidden user context, so the code puts `modelText` after the short line in the prompt of the resumed `query` call. A callback for a page with no `agentRef` gets a 410, and the agent can still read that answer with `get_page_result`. See [Handling answers](/docs/handling-answers).

## Troubleshooting

- The reply is a question in chat and has no link. For two or three short options the Page Systems guidance tells the agent to ask in chat. Send a request with more options or more detail, as in the prompt above, or use a stronger tool-calling model.
- `Page Systems ... 400 invalid_request` with "callbackUrl: cannot be a local address" or "must use https". Use a tunnel's https URL as described in [Test it on your machine](#test-it-on-your-machine).
- The page works but no new reply appears. Check that the tunnel is still running and that `CALLBACK_URL` is the current tunnel host. Pages created before a tunnel restart still point to the old host.
- The log line `PAGE_SYSTEMS_WEBHOOK_SECRET is not set` is a warning. Callbacks are accepted without signature checks until you set the secret.

## Next steps

- Browse the [page types](/docs) your agent can send.
- Open the [runnable example](https://github.com/dennisonbertram/pagesystems/tree/main/examples/guides/claude-agent-sdk) on GitHub.
