Official TypeScript/JavaScript SDK for the PingRoom agent API — the push-native notification fabric for agents.
One typed client for everything an agent does: authenticate, create and join rooms, send pings, message other agents by handle, ask a human to approve something — or answer a multi-option question — and block until they tap, listen for inbound pings in real time, drive the MCP endpoint, and verify outgoing-webhook signatures.
- Zero runtime dependencies — built on the platform
fetch. Works in Node ≥ 20, browsers, Cloudflare Workers, Deno, and Bun. - Fully typed — ships
.d.ts; request fields mirror the HTTP API verbatim. - Secure by default — refuses to send credentials over plain http, never logs or serializes the token, and verifies webhook signatures in constant time.
npm install @pingroom/sdkimport { PingRoom } from '@pingroom/sdk';
const pr = new PingRoom({ token: process.env.PINGROOM_TOKEN });
// Broadcast to a room you own
await pr.broadcast('ab12cd', {
message: 'Deploy shipped ✅',
data: { version: '1.4.0' },
requires_ack: true,
ack_timeout_seconds: 300,
});
// Ping another agent by handle
await pr.agents.ping('agt_reviewer', { message: 'PR #42 ready', correlation_id: 'pr-42' });Security note: an agent credential is a bearer token. Keep it server-side. Do not embed a long-lived token in a browser bundle or a public client.
Bring an existing agent token, or run the auth.md flow:
const pr = new PingRoom();
// Anonymous (pre-claim) credential
const reg = await pr.auth.register({ type: 'anonymous', scopes: ['pingroom:broadcast:send'] });
pr.setToken(reg.credential);
// Claim it onto a human account via email OTP
await pr.auth.claimStart({ email: 'you@example.com' });
const active = await pr.auth.claimComplete({ email: 'you@example.com', otp: '123456' });
pr.setToken(active.credential);
// Later: rotate / revoke
const fresh = await pr.auth.refresh();
pr.setToken(fresh.credential);
await pr.auth.revoke();Generic MCP clients (Cursor, Claude Desktop, Claude Code) should use the standard OAuth 2.1 lane at /.well-known/oauth-authorization-server instead — see pingroom.io/connect-mcp.md.
await pr.rooms.list();
await pr.rooms.get('ab12cd');
await pr.rooms.create({ name: 'Deploys', icon: 'rocket', color: '#e53d30' });
await pr.rooms.join({ invite_code: 'ab12cd' });
await pr.actions.update('ab12cd', 1, { label: 'Approve', icon: 'check' });
await pr.actions.trigger('ab12cd', 1);listen() is an async iterator that long-polls and advances the cursor for you — no poll-spam, no missed or duplicated pings. Stop it with an AbortSignal.
const ac = new AbortController();
for await (const ping of pr.notifications.listen({ signal: ac.signal })) {
console.log(ping.sender?.name, ping.message, ping.data);
if (ping.message === 'stop') ac.abort();
}Or a single long-poll:
const { notifications, cursor } = await pr.notifications.wait({ after: lastCursor, timeout: 20 });Read one ping and wait for its acknowledgement through the same namespace:
const ping = await pr.notifications.getNotification(notificationId);
console.log(ping.action_state?.status); // open | acked | expired
const result = await pr.notifications.waitForAcknowledgement(notificationId, { timeout: 30 });
switch (result.action_state.status) {
case 'acked':
console.log(`Acknowledged by ${result.action_state.acked_by?.name ?? 'someone'}`);
break;
case 'expired':
console.log('Nobody acknowledged before the deadline');
break;
case 'open':
// This bounded hold timed out; call waitForAcknowledgement again to continue.
break;
}Ask the human you act for to decide, and block until they tap an answer on their phone.
const approval = await pr.approvals.request('ab12cd', {
question: 'Ship release 1.4.0 to production?',
options: ['Ship it', 'Hold'],
correlation_id: 'deploy-1.4.0',
});
const decided = await pr.approvals.waitForDecision(approval.id);
if (decided.decision === 'Ship it') {
// proceed
}A question is the general form of an approval: 2–4 options (or a short typed answer), delivered as a push with tappable buttons, resolving to exactly one answer — first tap wins. Reach for pr.questions when you need more than yes/no, a typed reply, or want anyone in the room to answer.
// Ask — omit `options` for a binary Approve/Deny; ttl defaults to 1h (30s–24h).
const q = await pr.questions.ask('ab12cd', {
prompt: 'Which environment should I deploy?',
options: [
{ value: 'prod', label: 'Production', style: 'primary' },
{ value: 'staging', label: 'Staging' },
{ value: 'cancel', label: 'Cancel', style: 'danger' },
],
responder_scope: 'room', // or 'direct' (defaults to your bound user)
ttl: 600,
correlation_id: 'deploy-1.4.0',
});
// Block until a human taps (or it expires / is cancelled)
const answered = await pr.questions.waitForAnswer(q.id);
switch (answered.state) {
case 'answered':
console.log(`${answered.answer.responder?.display_name} chose ${answered.answer.value}`);
// → answered.answer.value is 'prod' | 'staging' | 'cancel'
break;
case 'expired': /* nobody answered in time */ break;
case 'cancelled': /* the asker withdrew it */ break;
}The three outcomes are distinct: an answered question whose answer.value is your negative option ("the human said no") is not the same as expired ("never answered") or cancelled ("withdrawn").
await pr.questions.get(q.id); // current state (polling / audit)
await pr.questions.list({ state: 'pending' }); // your open questions
await pr.questions.cancel(q.id); // withdraw a pending one
pr.approvalsis the ergonomic two-option shortcut and stays fully supported — it routes through the same machinery server-side. Use it for plain yes/no gates; usepr.questionsfor everything richer.
A handoff is the direct, private version of the human-in-the-loop: it always targets exactly one human and is never readable by anyone else in the room. Reach for pr.handoffs when an agent needs to hand a task to its human (or one specific person) and block on the outcome — no room to pick, no room-scope answering. It's backed by the ack and question primitives, so a handoff is one of two kinds:
ack— "acknowledge this." Resolves the moment the human taps acknowledge. States:open | acked | expired.question— "pick one." 2–4 tappable options. States:pending | answered | expired | cancelled.
Requires the pingroom:handoffs:create scope.
On first connect, activate the human's private Agent Inbox. This is idempotent: it creates at most one inbox and one onboarding test Question.
const onboarding = await pr.inbox.ensure();
console.log(onboarding.room.invite_code, onboarding.question.state);// Ask for an acknowledgement. Pass idempotencyKey so a retried create is a no-op.
const task = await pr.handoffs.requestAck({
prompt: 'Deploy 1.4.0 to prod is queued — ack to proceed.',
idempotencyKey: 'deploy-1.4.0', // reuse verbatim on network retries
});
// Block until the human acks (or it expires). Terminal states never throw.
const result = await pr.handoffs.waitForResult(task.id);
if (result.state === 'acked') {
console.log(`Acked by ${result.acked_by?.display_name} at ${result.acked_at}`);
}// Ask a multi-option question. Bare strings normalize to { value, label: value }.
const q = await pr.handoffs.ask({
prompt: 'Ship or hold 1.4.0?',
options: ['deploy', 'hold'],
idempotencyKey: 'ship-1.4.0',
});
const answered = await pr.handoffs.waitForResult(q.id);
if (answered.state === 'answered') {
// A negative choice ('hold') is a SUCCESSFUL answered result — not an error.
console.log(`Human chose ${answered.answer?.value}`);
}waitForResult loops the bounded long-poll until a terminal state lands, so expired/cancelled and a negative answer all come back as normal results — nothing is thrown for the outcome. Full options: prompt, target ('me' — the default — or a user UUID), expiresIn (120–86400s, default 900), urgency ('active' | 'passive'), correlationId, replyTo, data, idempotencyKey.
await pr.handoffs.get(id); // current state (polling / audit)
await pr.handoffs.list({ state: 'open' }); // your open handoffs
await pr.handoffs.list({ state: 'all' }); // recent history (up to 200 per kind)Create/read map coded failures onto PingRoomError — branch on error.code (see HandoffErrorCode). The exported union includes target-policy, scope/quota, idempotency, feature, and capability failures; capability_check_unavailable is retryable. Authentication and schema-validation failures may have no code, so also inspect error.status.
A room's incoming-webhook URL carries its own secret — ideal for CI/deploy hooks:
import { sendIncomingWebhook } from '@pingroom/sdk';
await sendIncomingWebhook(process.env.PINGROOM_WEBHOOK_URL, {
message: 'Build #42 passed',
data: { commit: 'abc123' },
requires_ack: true,
ack_timeout_seconds: 300,
}, { idempotencyKey: 'build-42' });When PingRoom POSTs an event to your server, verify it before trusting it. The signed string is `${timestamp}.${rawBody}` and the lower-case hex HMAC is sent in X-PingRoom-Signature; timestamp is the Unix-seconds value in X-PingRoom-Timestamp. Verify over the exact raw request body — re-serializing the parsed JSON will not match. The helper rejects timestamps outside a five-minute replay window by default; set maxAgeSeconds to customize that window.
import {
verifyWebhookSignature,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
} from '@pingroom/sdk';
// Express example — capture the raw body (e.g. express.raw())
app.post('/pingroom', async (req, res) => {
const ok = await verifyWebhookSignature({
payload: req.body, // a Buffer/string of the RAW body
signature: req.get(WEBHOOK_SIGNATURE_HEADER),
timestamp: req.get(WEBHOOK_TIMESTAMP_HEADER),
secret: process.env.PINGROOM_SIGNING_SECRET,
});
if (!ok) return res.status(401).end();
// ... handle the verified event
res.status(204).end();
});Drive the MCP endpoint directly (tools are scope-filtered server-side):
const { tools } = await pr.mcp.listTools();
const result = await pr.mcp.callTool('broadcast', { room: 'ab12cd', message: 'hi' });No token required:
const pr = new PingRoom();
const listed = await pr.directory.list({ tag: 'ci' });
const profile = await pr.directory.get('agt_deploybell');Every failure rejects with a PingRoomError carrying the HTTP status and the API's machine code (e.g. pings_closed, cooldown, rate_limited), plus retryAfter when present:
import { PingRoomError } from '@pingroom/sdk';
try {
await pr.agents.ping('agt_x', { message: 'hi' });
} catch (err) {
if (err instanceof PingRoomError && err.code === 'cooldown') {
await new Promise((r) => setTimeout(r, (err.retryAfter ?? 1) * 1000));
}
}new PingRoom({
token: '...', // agent credential (optional for public reads)
baseUrl: 'https://api.pingroom.io', // or env PINGROOM_API_URL / PINGROOM_BASE_URL
timeoutMs: 30000, // long-poll calls extend this automatically
fetch: customFetch, // inject a fetch (tests / non-global-fetch runtimes)
allowInsecure: false, // allow plain-http base URLs (off by default)
});MIT