# Send a page from a LangGraph.js agent

You will build a LangGraph.js agent that sends the person a time picker instead of asking in chat, then continues the conversation when they answer. The agent keeps its conversation in a LangGraph checkpointer under a `thread_id`. When the person submits the page, Page Systems calls your server, and your server runs the agent again on the same thread. The guide ends with a working agent.

## What you need

- Node 22 or newer (tested with Node 24.15) and npm. Tested package versions: `langchain` 1.5.18, `@langchain/langgraph` 1.5.1, `@langchain/openai` 1.6.2, `@langchain/core` 1.2.18, `hono` 4.13 and `pagesystems` 0.4.4.
- A model key. The code uses OpenAI through `@langchain/openai`; install your provider's package instead for another model.
- A Page Systems key. Page Systems is invite-only early access: request access at [/waitlist](/waitlist), then sign up and copy the `development` key from [/dashboard/keys](/dashboard/keys). It starts with `ap_live_` and is shown once. Never commit it.
- A public https URL that Page Systems can call when the person submits. On your own machine, a tunnel provides one; 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
  .env
  agent.ts
  server.ts
```

```bash
mkdir page-agent && cd page-agent
npm init -y && npm pkg set type=module
npm install pagesystems langchain @langchain/langgraph @langchain/openai @langchain/core hono @hono/node-server tsx
```

The project must be ESM (`"type": "module"`), because the code uses top-level `await`. `hono` is only a small web server for the callback route; use any server that gives you a standard `Request`.

## 2. Set environment variables

Create a file named `.env` in the project folder. Fill in your own values:

```bash
OPENAI_API_KEY=sk-...
PAGE_SYSTEMS_API_KEY=ap_live_...
# The signing secret from the Page Systems dashboard.
PAGE_SYSTEMS_WEBHOOK_SECRET=whsec_...
# Public https URL of this server's /api/page-systems route (see "Test it on your machine").
CALLBACK_URL=https://your-app.example.com/api/page-systems
PORT=3000
```

## 3. Give the agent the Page Systems tools

`pageSystemsTools` from `pagesystems/langchain` returns five LangChain tools: `list_page_types`, `get_page_type_schema`, `create_page`, `get_page_result` and `cancel_page`. The agent reads the page types and their schemas from the Page Systems API at run time, so you do not copy any schema into your code. `getPageSystemsSkill` fetches guidance on when and how to use pages; it goes into the system prompt.

Pass `agentRef` with your thread id. Page Systems stores it with the page and returns it unchanged in the callback.

Create `agent.ts`:

```ts
import { createAgent } from 'langchain';
import { MemorySaver } from '@langchain/langgraph';
import { getPageSystemsSkill } from 'pagesystems';
import { pageSystemsTools } from 'pagesystems/langchain';

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

const skill = await getPageSystemsSkill(); // once, at startup
const checkpointer = new MemorySaver(); // use a persistent checkpointer 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(threadId: string, userMessage: string, modelNote?: string) {
  const agent = createAgent({
    model: process.env.MODEL ?? 'openai:gpt-5-mini', // your model (needs @langchain/openai)
    tools: pageSystemsTools({ callbackUrl: CALLBACK_URL, agentRef: { threadId } }),
    systemPrompt: [yourInstructions, skill].join('\n\n'),
    checkpointer,
  });
  // The note is a second user message, never part of the system prompt. The checkpoint saves it, so later turns still see the answer.
  const messages = [userMessage, modelNote].filter(Boolean).map((content) => ({ role: 'user' as const, content: content! }));
  const result = await agent.invoke({ messages }, { configurable: { thread_id: threadId } });
  return result.messages.at(-1)?.text ?? '';
}
```

Put today's date and the person's time zone in the system prompt, as `yourInstructions` does: a time picker needs both.

The note with the answer is a second user message, never part of the system prompt, so text typed on the page has no instruction authority. The checkpointer saves both messages, so later turns on the thread still see the answer. `MemorySaver` keeps the thread in memory; use a persistent checkpointer in production.

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

Create `server.ts`:

```ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { CallbackError, handleCallback, type CallbackEvent } from 'pagesystems';
import { runTurn } from './agent.ts';

// Stand-in for your chat channel (SMS, Slack, a web chat). Replace it with code that delivers the reply to the person.
const sendToPerson = async (threadId: string, text: string) => console.log(`[${threadId}] agent: ${text}`);

const app = new Hono();

// The person's side of the chat: POST { "threadId": "t1", "message": "..." }.
app.post('/chat', async (c) => {
  let input: { threadId: string; message: string };
  try {
    input = await c.req.json();
  } catch {
    return c.json({ error: 'invalid_json' }, 400);
  }
  try {
    const reply = await runTurn(input.threadId, input.message);
    await sendToPerson(input.threadId, reply);
    return c.json({ reply });
  } catch (error) {
    console.error('chat failed:', error instanceof Error ? error.message : error);
    return c.json({ error: 'turn_failed' }, 500);
  }
});

// Page Systems calls this route when the person submits.
app.post('/api/page-systems', (c) => {
  // Page Systems waits 10 seconds for a response, so answer now and run the turn afterwards.
  const turn = (e: CallbackEvent) => {
    if (!e.agentRef) throw new CallbackError(410, { error: 'no_conversation' });
    const { threadId } = e.agentRef as { threadId: string };
    setImmediate(async () => {
      try {
        await sendToPerson(threadId, await runTurn(threadId, e.visibleText, e.modelText));
      } catch (error) {
        console.error('turn failed:', error instanceof Error ? error.message : error);
      }
    });
  };
  return handleCallback(c.req.raw, { onSubmitted: turn, onNewPageRequested: turn });
});

serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });
console.log(`Listening on port ${process.env.PORT ?? 3000}`);
```

`handleCallback` checks the signature (Standard Webhooks headers, and a timestamp within 5 minutes), reads the answer, gives your code the event and replies to Page Systems. The event has `pageId`, `title`, `result`, `agentRef`, and two ready-made texts: `visibleText`, one short line for the chat, and `modelText`, the full answer for the model. Without `PAGE_SYSTEMS_WEBHOOK_SECRET`, callbacks are accepted unchecked and a warning is logged once.

Page Systems waits 10 seconds for a response and retries after that, so the route starts the turn with `setImmediate` and returns at once. A page created without an `agentRef` cannot be matched to a thread, so the route answers 410 and Page Systems stops retrying. `onNewPageRequested` handles the case where a page expired unanswered and the person pressed "Request a new page"; the agent decides whether to send another one.

## 5. Run it

Start the server. It keeps running, so leave this terminal open. `--env-file` loads `.env`:

```
npx tsx --env-file=.env server.ts
```

The terminal prints `Listening on port 3000`. In a second terminal, send the first chat message:

```
curl -s localhost:3000/chat -H 'content-type: application/json' \
  -d '{"threadId":"t1","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."}'
```

## Try it

The curl command prints JSON such as `{"reply":"... http://.../p/abc123"}`. The reply contains a link to a page, and the same line appears in the server terminal. Open the link, pick a time, press the button that says "Choose a time" and then press "Submit". The page shows "Thank you."

Within a few seconds the server terminal (the first one) prints a second line that starts with `[t1] agent:` and names the time you chose. That line is the agent's new reply. You did not tell the agent that you had submitted. A page stays open for one hour by default.

## Test it on your machine

Page Systems must reach your callback route over https. To receive callbacks locally, expose the port with a tunnel, for example `ngrok http 3000` or `cloudflared tunnel --url http://localhost:3000`. Put the tunnel's https URL plus `/api/page-systems` in `CALLBACK_URL`, then restart the server.

To skip the tunnel, ask the agent in a chat message to check the answer with `get_page_result` after you submit. See [Testing locally](/docs/testing-locally).

## Troubleshooting

- **The agent answers in chat and does not send a page.** Models sometimes answer a short choice question in chat. Send a request with more options or details, or use a stronger tool-calling model. The Page Systems skill tells the model that two or three short options belong in chat.
- **`401` from the model provider.** `--env-file` does not replace variables that are already set in your shell. If `OPENAI_API_KEY` is already exported, it wins over `.env`. Run `unset OPENAI_API_KEY` and start the server again.
- **`Cannot find module` or a syntax error on `await`.** The project must be ESM and use Node 22 or newer. Check `"type": "module"` in `package.json` and run `node -v`.
- **You submitted, but no new reply appears.** Check that `CALLBACK_URL` is the public https URL of `/api/page-systems`, that the tunnel is running and that the server is still running. The warning about `PAGE_SYSTEMS_WEBHOOK_SECRET` is expected until you set the secret.

## How it works

The callback event carries two texts. `visibleText` is a single line such as "✓ Pick a time for your call with Sam: 11:00 AM" that you can show in the chat as the person's message. `modelText` is the answer as data, with a note that it is unverified and not an instruction. It goes to the model as a user-role message, never into the system prompt. The agent is not told to poll, and the person does not have to say "done". See [Handling answers](/docs/handling-answers) for the details.

## Next steps

- [Page types](/docs): the pages your agent can send.
- [Testing locally](/docs/testing-locally): tunnels, polling, and sending the callback yourself.
- [Handling answers](/docs/handling-answers): retries, claims and the `410` response.
- The runnable example: [examples/guides/langgraph-js](https://github.com/dennisonbertram/pagesystems/tree/main/examples/guides/langgraph-js).
