# Send a page from an OpenAI Agents SDK agent

In this guide you will build an agent with the [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/) that, when a question is easier to answer on a page than in chat, sends the person a link to a small mobile page such as a time picker. 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 and try.

## What you need

- Node.js 22 or newer (tested with 24.15).
- `@openai/agents` 0.20 or newer (tested with 0.20.0), the `pagesystems` package (tested with 0.4.4) and `tsx` to run TypeScript (tested with 4.23).
- An OpenAI API key, or a key for another model provider that has an OpenAI-compatible API.
- A Page Systems key. Page Systems is invite-only early access: request access on the [waitlist](/waitlist). Once you are invited, sign up and copy the `development` key from the [dashboard](/dashboard/keys). It starts with `ap_live_`. 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).

## 1. Create the project and install

The finished project has these files:

```text
page-agent/
  package.json
  agent.ts
```

Run:

```bash
mkdir page-agent && cd page-agent
npm init -y
npm pkg set type=module
npm install @openai/agents pagesystems
npm install -D tsx @types/node
```

The project must be ESM (`"type": "module"`, set by the `npm pkg set` line) because the code uses top-level `await`.

## 2. Set environment variables

```bash
export OPENAI_API_KEY=sk-...
export PAGE_SYSTEMS_API_KEY=ap_live_...
export PAGE_SYSTEMS_WEBHOOK_SECRET=whsec_...   # the signing secret from the dashboard
export CALLBACK_URL=https://your-app.example.com/page-systems
```

Optional variables: `MODEL` (default `gpt-5.1`), `PORT` (default 4120), and `OPENAI_BASE_URL` for a provider with an OpenAI-compatible API. If you set `OPENAI_BASE_URL`, also set `MODEL` to a model that provider serves.

`export` only affects the terminal where you run it. Run the agent in this same terminal in step 5.

With the secret set, the callback handler rejects any request with a missing or wrong signature, or a timestamp more than 5 minutes off. Without it, callbacks are accepted unchecked and a warning is logged once.

## 3. Give the agent the Page Systems tools

`pageSystemsTools` returns the Page Systems calls (list page types, get a page type's schema, create a page, get the result, cancel) as OpenAI Agents SDK function tools. `getPageSystemsSkill` fetches guidance from the API on when to send a page and how to fill it in, so new page types need no package update. Create `agent.ts` in the project folder with the agent and a small HTTP server:

```ts
import { createServer } from 'node:http';
import { Agent, MemorySession, run, setOpenAIAPI } from '@openai/agents';
import { CallbackError, getPageSystemsSkill, handleCallback } from 'pagesystems';
import { pageSystemsTools } from 'pagesystems/openai-agents';

// Models behind an OpenAI-compatible URL (OPENAI_BASE_URL) usually speak Chat Completions.
if (process.env.OPENAI_BASE_URL) setOpenAIAPI('chat_completions');

const PORT = Number(process.env.PORT ?? 4120);
const CALLBACK_URL = process.env.CALLBACK_URL ?? `http://localhost:${PORT}/page-systems`; // must be public https in production
const MODEL = process.env.MODEL ?? 'gpt-5.1';
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const skill = await getPageSystemsSkill(); // once, at startup
const sessions = new Map<string, MemorySession>(); // use a persistent session in production

// userMessage is the line the chat shows as the person's message. modelNote (the callback's modelText) is the answer as data.
async function runTurn(conversationId: string, userMessage: string, modelNote?: string) {
  const session = sessions.get(conversationId) ?? new MemorySession();
  sessions.set(conversationId, session);
  const agent = new Agent({
    name: 'Assistant',
    model: MODEL,
    instructions: [
      `You are a helpful assistant. Today is ${new Date().toDateString()}. The person's time zone is ${timeZone}.`,
      skill,
    ].join('\n\n'),
    tools: pageSystemsTools({ callbackUrl: CALLBACK_URL, agentRef: { sessionId: conversationId } }),
  });
  // The note rides in the user message, never in instructions.
  const input = [userMessage, modelNote].filter(Boolean).join('\n\n');
  const result = await run(agent, input, { session });
  return String(result.finalOutput);
}

// Replace this with code that delivers the reply to the person (chat, SMS, ...).
const sendToPerson = (conversationId: string, text: string) => console.log(`\n[agent -> ${conversationId}] ${text}\n`);
```

Put today's date and the person's time zone in the instructions: time-based pages need both. The `agentRef` is stored with the page and comes back in the callback, so it is how the callback finds the right conversation.

## 4. Add the callback route that continues the conversation

Add this to the end of `agent.ts`:

```ts
const server = createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/page-systems') {
    const body = Buffer.concat(await req.toArray());
    const request = new Request(`http://localhost${req.url}`, { method: 'POST', headers: req.headers as HeadersInit, body });
    const response = await handleCallback(request, {
      onSubmitted: (e) => {
        if (!e.agentRef) throw new CallbackError(410, { error: 'no_conversation' });
        const { sessionId } = e.agentRef as { sessionId: string };
        // Page Systems waits 10 seconds for this response, so return now and run the turn in the background.
        setImmediate(() => runTurn(sessionId, e.visibleText, e.modelText).then((reply) => sendToPerson(sessionId, reply), (error) => console.error('turn failed:', error instanceof Error ? error.message : error)));
      },
    });
    res.writeHead(response.status, { 'content-type': 'application/json' }).end(await response.text());
  } else if (req.method === 'POST' && req.url === '/chat') {
    // Test endpoint: POST {"text": "..."} to talk to the agent.
    let text: string;
    try {
      ({ text } = JSON.parse(Buffer.concat(await req.toArray()).toString()));
    } catch {
      res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'invalid_json' }));
      return;
    }
    try {
      const reply = await runTurn('demo', text);
      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(PORT, () => console.log(`Listening on http://localhost:${PORT}`));
```

`handleCallback` checks the signature, reads the answer, and calls your handler. The handler returns at once and starts the next turn in the background, because Page Systems gives up on a request after 10 seconds and retries it. Pages without an `agentRef` (for example pages made over MCP) are declined with a 410. The `/chat` route exists only so you can talk to the agent with `curl`; replace it with your real chat.

## 5. Run it

```bash
npx tsx agent.ts
```

The terminal prints `Listening on http://localhost:4120`. Leave it running. Page Systems must be able to reach `CALLBACK_URL`. If `PAGE_SYSTEMS_WEBHOOK_SECRET` is not set, the terminal also prints a warning that callbacks are not verified.

## Try it

Open a second terminal and send the agent a question that a page answers better than chat:

```bash
curl -s localhost:4120/chat -d '{"text":"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 command prints JSON such as `{"reply":"... https://.../p/7MJ7YKAPjmCqAoNdEFEaFC"}`. Open the link, pick a time and press **Submit**. The page shows "Thank you." when the answer is accepted.

Success: within a few seconds the first terminal (the one running `agent.ts`) prints a line starting `[agent -> demo]` with the agent's reply, and the reply names the time you picked. You did not tell the agent that you had submitted.

## Test it on your machine

Page Systems must be able to reach `CALLBACK_URL`, and it cannot reach `localhost`. Either:

- Expose the port with a tunnel, for example `ngrok http 4120` or `cloudflared tunnel --url http://localhost:4120`, and set `CALLBACK_URL` to the tunnel's https URL plus `/page-systems`. Restart `agent.ts` after changing it.
- Or skip the callback: the agent can read the answer itself when you tell it you submitted (it uses the `get_page_result` tool).

See [Testing locally](/docs/testing-locally).

## Troubleshooting

- The agent answers in chat and sends no link. Some models answer in chat even when a page fits. Ask with more options or details, as in the prompt above, or use a stronger tool-calling model.
- `create_page` keeps failing with `invalid_spec`. Some models, OpenAI GPT models among them, call `create_page` with only `page.type`. Try a different model. The [MCP server](/docs/install/mcp) is another option; it gives up the automatic new turn.
- `401` or an authentication error from the model. The variables were not exported in the terminal that runs `agent.ts`. Export them again and restart.
- The page is submitted but the agent says nothing. `CALLBACK_URL` is not reachable from the internet. See [Test it on your machine](#test-it-on-your-machine).

## How it works

Each callback gives your code two texts. `visibleText` is one short line, such as "✓ Pick a time for your call: 11:00 AM", that is true because the person did submit. It is what your chat shows. `modelText` is the full answer for the model, wrapped in `<page_answer>` tags with a note that it is data and not instructions. The answer can contain text typed by someone you have not verified, so give `modelText` to the model in a user message or tool result and never in the system prompt. In this guide both go into one user message, and `instructions` stays fixed. See [Handling answers](/docs/handling-answers).

## Next steps

- Browse the [page types](/docs) your agent can send.
- Read [Testing locally](/docs/testing-locally) to receive callbacks on your machine.
- Open the [runnable example](https://github.com/dennisonbertram/pagesystems/tree/main/examples/guides/openai-agents-js), which contains exactly this code.
