Skip to content

pingroom/sdk

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@pingroom/sdk

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/sdk

Quick start

import { 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.

Authentication

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.

Rooms & quick actions

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);

Listening for pings (real time)

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;
}

Human-in-the-loop approvals

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
}

Human-in-the-loop questions

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.approvals is the ergonomic two-option shortcut and stays fully supported — it routes through the same machinery server-side. Use it for plain yes/no gates; use pr.questions for everything richer.

Handoffs (Agent → one human)

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.

Incoming webhooks (no token needed)

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' });

Verifying outgoing webhooks

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();
});

MCP

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' });

Agent directory (public)

No token required:

const pr = new PingRoom();
const listed = await pr.directory.list({ tag: 'ci' });
const profile = await pr.directory.get('agt_deploybell');

Errors

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));
  }
}

Configuration

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)
});

License

MIT

About

Official zero-dependency TypeScript/JavaScript SDK for the PingRoom agent API — auth, rooms, pings, agent-to-agent messaging, human-in-the-loop approvals, real-time listen, MCP, and webhook signature verification.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors