Guides / TypeScript / Claude Agent SDK
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-sdk0.3.303 andpagesystems0.4.4 (both installed in step 1), and anANTHROPIC_API_KEY.- 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.
When you finish step 4, the project looks like this:
page-agent/
.env
package.json
agent.ts
server.ts1. Create the project and install
mkdir page-agent && cd page-agent
npm init -y
npm pkg set type=module
npm install @anthropic-ai/claude-agent-sdk 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:
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-systems3. 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:
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:
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
npm pkg set scripts.start="node --env-file=.env server.ts"
npm startThe 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.
Try it
In the second terminal, send the agent a message the way your chat would:
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.
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.
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_requestwith "callbackUrl: cannot be a local address" or "must use https". Use a tunnel's https URL as described in Test it on your machine.- The page works but no new reply appears. Check that the tunnel is still running and that
CALLBACK_URLis 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 setis a warning. Callbacks are accepted without signature checks until you set the secret.
Next steps
- Browse the page types your agent can send.
- Open the runnable example on GitHub.