Skip to content
GuidesDashboard

Guides / TypeScript / OpenAI Agents SDK (TypeScript)

Send a page from an OpenAI Agents SDK agent

In this guide you will build an agent with the OpenAI Agents SDK 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. Once you are invited, sign up and copy the development key from the dashboard. 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.

1. Create the project and install

The finished project has these files:

page-agent/
  package.json
  agent.ts

Run:

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

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:

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:

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

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:

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.

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

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.

Next steps