Using API v1? See what changes in v2, or the v1 docs
LangChain
This guide adds a calendar assistant to your server with a LangChain agent. The agent uses Pyas's tools for the signed-in user only. When it wants to change a calendar, it pauses until the person approves.
The code was tested with LangChain 1.6, LangGraph 1.7, and OpenAI's gpt-5.5 and Anthropic's claude-sonnet-5-5 as the model. See AI in your app for how the tools work.
Install
npm install @pyas/ai-tools langchain @langchain/core @langchain/langgraph @langchain/openai
Set these on your server:
PYAS_API_KEY=pyas_sk_test_... # a test key from API keys in the Pyas dashboard
OPENAI_API_KEY=sk-...
For Claude, install @langchain/anthropic, set ANTHROPIC_API_KEY, and use anthropic:claude-sonnet-5-5 as the model.
The agent
assistant.ts
import { createAgent } from 'langchain'
import { Command, MemorySaver } from '@langchain/langgraph'
import { pyasToolkit, type PyasApprovalRequest } from '@pyas/ai-tools/langchain'
// Keeps each conversation between requests. In production, use a database checkpointer,
// such as PostgresSaver from @langchain/langgraph-checkpoint-postgres.
const checkpointer = new MemorySaver()
/** What to show the person: the assistant's reply, or the changes waiting for their approval. */
export type Turn =
| { reply: string }
| { changes: Array<{ id: string } & PyasApprovalRequest> }
function agentFor(userId: string) {
// The same for the whole conversation. Today's date lets the model work out "tomorrow".
const today = new Date().toISOString().slice(0, 10)
return createAgent({
model: 'openai:gpt-5.5',
tools: pyasToolkit({ subjectId: userId }),
systemPrompt: `You help the user manage their calendar. Today is ${today}.`,
checkpointer,
})
}
function turn(result: { messages: Array<{ text: string }>; __interrupt__?: Array<{ id?: string; value?: unknown }> }): Turn {
const waiting = result.__interrupt__ ?? []
if (waiting.length) return { changes: waiting.map((change) => ({ id: change.id!, ...(change.value as PyasApprovalRequest) })) }
return { reply: result.messages.at(-1)?.text ?? '' }
}
/** The person sends a message. conversationId is yours: one per conversation. */
export async function ask(conversationId: string, userId: string, text: string): Promise<Turn> {
const config = { configurable: { thread_id: conversationId } }
return turn(await agentFor(userId).invoke({ messages: [{ role: 'user', content: text }] }, config))
}
/** The person answers the changes: the ids they approved. Every other change is declined. */
export async function answer(conversationId: string, userId: string, changeIds: string[], approved: string[]): Promise<Turn> {
const config = { configurable: { thread_id: conversationId } }
const resume = Object.fromEntries(changeIds.map((id) => [id, { approved: approved.includes(id) }]))
return turn(await agentFor(userId).invoke(new Command({ resume }), config))
}
A tool that changes a calendar pauses the agent with LangGraph's interrupt(). The agent needs a checkpointer to pause: it saves the conversation, and the agent carries on from the same point once the person approves or declines. Outside an agent, such a tool fails rather than run without approval.
answer resumes the agent with { approved: true } or { approved: false } for each change, by the change's ID. A declined change is never sent to Pyas, and the model is told.
Keep the system prompt the same for the whole conversation. Some models, such as Claude, reject a conversation whose system prompt changes partway, so put today's date in it, not the time.
Your routes
Your server keeps each person's conversation ID and the changes waiting for them. With Express:
server.ts
import express from 'express'
import { randomUUID } from 'node:crypto'
import { answer, ask } from './assistant.js'
/** The signed-in person's ID in your app, from your sign-in (here, the sandbox's Avery Brooks). */
function currentUserId(_req: express.Request): string | null {
return 'sandbox_user_1'
}
// Each person's conversation and the changes waiting for them. In production, keep these in your database.
const conversations = new Map<string, { id: string; waiting: string[] }>()
const app = express()
app.use(express.json())
app.post('/chat', async (req, res) => {
const userId = currentUserId(req)
if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' })
const conversation = conversations.get(userId) ?? { id: randomUUID(), waiting: [] }
conversations.set(userId, conversation)
const turn = await ask(conversation.id, userId, req.body.text)
conversation.waiting = 'changes' in turn ? turn.changes.map((change) => change.id) : []
res.json(turn)
})
// The person's answer: { "approved": ["..."] }. Changes left out are declined.
app.post('/chat/answer', async (req, res) => {
const userId = currentUserId(req)
if (!userId) return res.status(401).json({ error: 'Sign in to use your calendar.' })
const conversation = conversations.get(userId)
if (!conversation?.waiting.length) return res.status(409).json({ error: 'No change is waiting for an answer.' })
const turn = await answer(conversation.id, userId, conversation.waiting, req.body.approved ?? [])
conversation.waiting = 'changes' in turn ? turn.changes.map((change) => change.id) : []
res.json(turn)
})
app.listen(3457)
currentUserId stands for your sign-in. It returns the ID you saved as subjectId when the person connected their calendar. To try the guide in the sandbox, have it return 'sandbox_user_1', the sandbox person Avery Brooks.
Show the change
When the agent wants to change something, ask returns the changes instead of a reply:
{
"changes": [
{
"id": "c9d154673bb91cf774e8a964e2891f8c",
"type": "pyas_approval",
"tool": "delete_event",
"title": "Delete an event",
"input": { "accountId": "sbx_f7bb6446c0_1", "eventId": "sbc90a16505a04a760bc4a34a0" },
"event": {
"id": "sbc90a16505a04a760bc4a34a0",
"title": "Design Review",
"startsAt": "2026-11-10T20:00:00Z",
"endsAt": "2026-11-10T21:00:00Z",
"startsAtLocal": "2026-11-10T15:00:00-05:00",
"endsAtLocal": "2026-11-10T16:00:00-05:00",
"timezone": "America/New_York",
"isAllDay": false,
"location": "Room 4B",
"version": "\"1\""
}
}
]
}
Show each change with Approve and Decline:
titlesays what the change does.eventis the event as it is now in Pyas, for a change to an event that already exists. Show its title and time: it's what the change is really about, whatever the model says. A new event has noevent, so show the title and times frominput.
Send the IDs the person approved to /chat/answer. The others are declined.

Try it
- Run your server with a test key, and with
currentUserIdreturning'sandbox_user_1'. - Ask "What's on my calendar tomorrow?". The reply lists Avery's events, without pausing.
- Ask "Book a 30 minute meeting tomorrow afternoon when I'm free." The agent pauses with one change, "Create an event". Approve it, and the reply says it's booked.
- Ask it to delete that meeting, and decline. The meeting stays, and the reply says it was left as it was.
To go live, see Go live.