Guides / TypeScript / LangGraph.js
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:
langchain1.5.18,@langchain/langgraph1.5.1,@langchain/openai1.6.2,@langchain/core1.2.18,hono4.13 andpagesystems0.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, then sign up and copy the
developmentkey from /dashboard/keys. It starts withap_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.
1. Create the project and install
The finished project has these files:
page-agent/
package.json
.env
agent.ts
server.tsmkdir 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 tsxThe 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:
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=30003. 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:
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:
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.tsThe 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.
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.
401from the model provider.--env-filedoes not replace variables that are already set in your shell. IfOPENAI_API_KEYis already exported, it wins over.env. Rununset OPENAI_API_KEYand start the server again.Cannot find moduleor a syntax error onawait. The project must be ESM and use Node 22 or newer. Check"type": "module"inpackage.jsonand runnode -v.- You submitted, but no new reply appears. Check that
CALLBACK_URLis the public https URL of/api/page-systems, that the tunnel is running and that the server is still running. The warning aboutPAGE_SYSTEMS_WEBHOOK_SECRETis 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 for the details.
Next steps
- Page types: the pages your agent can send.
- Testing locally: tunnels, polling, and sending the callback yourself.
- Handling answers: retries, claims and the
410response. - The runnable example: examples/guides/langgraph-js.