Build

Build an agent

Receive messages, call your own model, reply in the right place and handle long-running work.

Every Pacerelle agent follows the same loop: receive a message, do the work, reply. The SDK takes care of the connection and the encryption; you only write the handler.

client.onMessage(async (message, agent) => {
  const answer = await doTheWork(message.text);
  await agent.reply(message, { text: answer });
});

If you haven't connected an agent yet, start with the Quickstart.

The message object

Your handler receives the decrypted message and a client to answer with.

JavaScriptPythonDescription
ididUnique message ID. Use it to reply and as an idempotency key.
conversationIdconversation_idThe conversation the message belongs to.
fromfrom_idThe sender — the person (or agent) to answer.
texttextThe message text.
attachmentsattachmentsFiles sent with the message. See Files and media.
widgetResponsewidget_responsePresent when the message is an answer to a widget.
replyToMessageIdreply_to_message_idSet when the sender replied to a specific message.

Reply in the right place

Always answer with the incoming message's conversation and sender, and pass its ID as the message you reply to. The SDK then routes the answer to the exact device that asked, even if another message arrived in the meantime.

// reply() fills in the conversation, the recipient and the source message.
await agent.reply(message, { text: "Done." });

// Equivalent, fully explicit:
await agent.sendMessage({
  conversationId: message.conversationId,
  to: message.from,
  replyToMessageId: message.id,
  text: "Done.",
});

You can send several messages for one request — a quick acknowledgment, then the result.

Connect a model

Pacerelle doesn't care which model you use. This example uses the OpenAI SDK, which also works with any OpenAI-compatible endpoint — including a local Ollama server. It keeps a short history per conversation so the model remembers context.

agent.mjs
import OpenAI from "openai";
import {
  createNodeAgentGatewayClient,
  createNodeSignalSnapshotStore,
} from "@pacerelle/sdk/node";

// Reads OPENAI_API_KEY. For Ollama: new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" })
const openai = new OpenAI();
const histories = new Map(); // conversationId → recent messages

const agentId = process.env.PACERELLE_AGENT_ID;
const state = createNodeSignalSnapshotStore(agentId);
const client = createNodeAgentGatewayClient({
  agentId,
  token: process.env.PACERELLE_AGENT_TOKEN,
  e2ee: true,
  signalSnapshot: state.signalSnapshot,
  onSignalSnapshot: state.onSignalSnapshot,
});

client.onMessage(async (message, agent) => {
  if (message.widgetResponse || !message.text) return;

  const history = histories.get(message.conversationId) ?? [];
  history.push({ role: "user", content: message.text });

  // Shows "typing…" in the app while the model thinks.
  agent.sendTyping({ conversationId: message.conversationId, to: message.from });

  const completion = await openai.chat.completions.create({
    model: process.env.OPENAI_MODEL,
    messages: [
      { role: "system", content: "You are a helpful assistant running on my computer." },
      ...history.slice(-20),
    ],
  });

  const answer = completion.choices[0].message.content ?? "";
  history.push({ role: "assistant", content: answer });
  histories.set(message.conversationId, history);

  await agent.reply(message, { text: answer });
});

await client.connect();

Add your model settings next to the Pacerelle configuration:

.env
PACERELLE_AGENT_ID=agent_...
PACERELLE_AGENT_TOKEN=...
OPENAI_API_KEY=sk-...
OPENAI_MODEL=your-model-name
Any provider, any framework

Swap the OpenAI call for Anthropic, Google, Mistral, LangChain, LlamaIndex, CrewAI or your own code. The Pacerelle part — receive, reply — stays exactly the same.

Show progress

For anything that takes more than a few seconds, let the user know the agent is working:

  • Typing indicatoragent.sendTyping({ conversationId, to }) in JavaScript.
  • Quick acknowledgment — reply On it… right away, then send the result.
  • Progress bar — a progress widget you update as work advances.

Long-running work

Messages are handled one at a time, in order. When your handler returns successfully, the SDK marks the message as handled and acknowledges it to the relay. If the handler throws or the process crashes first, the message can be delivered again later.

For work that takes minutes or hours:

  1. Save the job somewhere durable (a file, a database, a queue) before the handler returns.
  2. Return quickly, optionally with a first reply such as Started — I'll report back.
  3. When the job finishes, send the result with replyToMessageId set to the original message ID.

Use the message id as an idempotency key so a redelivered message never runs a job twice. See Production checklist.

Read earlier messages

After a restart, the JavaScript SDK can fetch the recent messages your agent received in a conversation to rebuild context. Pass before or beforeId to page further back.

const recent = await client.listConversationMessages({
  conversationId: message.conversationId,
  limit: 50,
  skipUndecodable: true,
});

Next steps