For developers

The person API

Build an app that works as the person who signs it in: it shows their cards, answers one when they press something in it, and keeps their phone quiet while they’re there. Herdr’s plugin is the first.

What an app may do

An app acts as the person, and only when they press something in it. Pending You never answers for them, and neither may your app.

It may

  • See the cards the person’s assistants ask them, on one computer or all of them.
  • Answer a card, write to its assistant, put it in Later, or hand it to another assistant.
  • See which assistants are running, and in which terminal.
  • Keep the person’s phone quiet while they’re using it.

It never may

  • Act on a high-stakes card (it spends money, can’t be undone or goes public). It can show one: send the person to the card’s url.
  • Answer on a timer, from a model’s decision, or for a card the person hasn’t seen.
  • See or change their account, sign-in, billing, secrets or assistants.
Every answer says where it came from
The card, its history and the assistant that asked all read “Answered in Herdr on build-01”. Answers are limited to 30 a minute, watched for bursts, and the person removes an app in one tap.

Apps and review

Every app is registered with Pending You. Register yours in Settings › Developers: it works for you at once, and for everyone once we’ve reviewed it.

  • Yours works for you at once.An app you register works for your own account and up to 10 testers while it waits for review.
  • Everyone else needs it verified.Review checks who publishes it, its domain, its name and logo, its privacy policy, that its scopes match what it does, and that every answer comes from a press by the person.
  • The person sees who it is.Its name, its publisher and whether it’s verified are on the approval, with what it may do in plain words.
  • You agree to the developer terms.Use what your app sees only to serve the person who signed it in: never sell it or train models on it. Read the developer terms.

Register your app

In Pending You, open Settings › Developers and press Register an app. You get a client ID at once, and a client secret if your app is a website with a server.

  1. Name it, and say how it signs in.A terminal or script signs in with a code on a computer set up with Pending You. A desktop or phone app, a website with a server, or a single-page web app signs in with PKCE and comes back to one of its redirect addresses.
  2. List its redirect addresses, exactly.https on your website’s domain or one under it; http://127.0.0.1 or http://localhost on any port; or, for a desktop or phone app, its own scheme named for your domain backwards (example.acme.desk:/oauth for acme.example). No wildcards, at most 10.
  3. Choose the most it may ask for.Its scopes’ ceiling: a sign-in asks for some or all of them, and the person sees each in plain words. A web or desktop app has no computer of its own, so it always holds cards:read:all.
  4. Keep the secret on your server.A website’s client secret is shown once. Rotate it in Settings › Developers; the old one stops working at once.
It works for you and your testers right away
Name up to 10 testers by the address they sign in to Pending You with. Anyone else needs it verified. You can have 5 apps waiting to be verified at once.

Before review

  • Prove your domain.Add a TXT record pendingyou-app-verification=<token> to it, or put that line in https://<domain>/.well-known/pendingyou-app-verification.txt, then press Check now. Settings › Developers shows your token.
  • Add a privacy policy, terms and a support email.The policy says what your app keeps of people’s cards, and where. They stay as they are while we review it.
  • Press Request review.We check who publishes it, its domain and redirect addresses, its name, its privacy policy, that its scopes match what it does, and that every answer comes from a press by the person. We write to its support email if something needs to change.

Signing in

OAuth 2.1 at Pending You, for the resource /v1. Terminals and plugins sign in by device code, proving which computer they’re on; web and desktop apps sign in with authorization code and PKCE (below).

  1. Make a key for the sign-in.A P-256 key that stays with your app (see DPoP below). Its thumbprint goes in the computer’s attestation, so the sign-in is bound to it from the start.
  2. Have the computer vouch for it, and start.pendingyou machine attest signs an attestation with the computer’s own key (made by npx pendingyou init). A computer the person hasn’t set up can’t be allowed.
  3. Show the code, and poll.The person allows it on their phone, on the card Pending You shows them, or at the link. Poll the token endpoint every interval seconds with a DPoP proof each time.
StartShell
curl -X POST https://www.pendingyou.com/oauth/device \
-d client_id=YOUR-CLIENT-ID \
-d scope='cards:read cards:answer' \
-d resource=https://www.pendingyou.com/v1 \
-d machine=build-01 \
--data-urlencode machine_attestation="$(npx -y pendingyou@latest machine attest \
--client-id YOUR-CLIENT-ID --cnf YOUR-KEY-THUMBPRINT --name build-01)"
What comes backJSON
{
"device_code": "Gq7…",
"user_code": "WDJB-MJHT",
"verification_uri": "https://www.pendingyou.com/device",
"verification_uri_complete": "https://www.pendingyou.com/device?code=WDJB-MJHT",
"expires_in": 600,
"interval": 3,
"client_id": "YOUR-CLIENT-ID"
}
PollShell
curl -X POST https://www.pendingyou.com/oauth/token \
-H "DPoP: $PROOF" \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=Gq7… \
-d client_id=YOUR-CLIENT-ID
JSONJSON
{
"access_token": "usr_…:…:…",
"token_type": "DPoP",
"expires_in": 900,
"refresh_token": "usr_…:…:…",
"scope": "cards:read cards:answer"
}
One sign-in per computer
Access tokens last 15 minutes. Refresh tokens change with every use. A sign-in bound to a computer’s key renews each time it’s used; any other lasts 30 days. Signing the same app in again on the same computer replaces the old sign-in.

Example:Signing in with a code, in a whole appexamples/answer-cards/sign-in.ts

Sign in from a web or desktop app

A desktop app, a single-page app or a website signs in through the person’s browser: authorization code with PKCE (S256 only), for the resource /v1. The person sees your app’s card, with where it goes back to, and allows it.

  1. Make a key, a verifier and a state.A P-256 key for the sign-in (see DPoP below), a PKCE verifier, and a random state. Name the key’s thumbprint as dpop_jkt (RFC 9449 §10), so the code is bound to it too.
  2. Send the person to Pending You.Use one of your app’s registered redirect addresses, exactly. A desktop app may listen on any port of its registered http://127.0.0.1 address (RFC 8252), or come back through its own scheme.
  3. Exchange the code.Check the state that comes back, then exchange the code with the verifier and a DPoP proof by your key. A desktop or single-page app must prove it; Pending You asks for a nonce first.
Where to send the personText
https://www.pendingyou.com/oauth/authorize
?response_type=code
&client_id=YOUR-CLIENT-ID
&redirect_uri=http://127.0.0.1:53682/callback
&scope=cards:read cards:read:all cards:answer
&state=RANDOM-STATE
&code_challenge=BASE64URL-SHA256-OF-THE-VERIFIER
&code_challenge_method=S256
&resource=https://www.pendingyou.com/v1
&dpop_jkt=YOUR-KEY-THUMBPRINT
What comes backText
http://127.0.0.1:53682/callback?code=usr_…:…:…&state=RANDOM-STATE&iss=https://www.pendingyou.com
Exchange the codeShell
curl -X POST https://www.pendingyou.com/oauth/token \
-H "DPoP: $PROOF" \
-d grant_type=authorization_code \
-d code=usr_…:…:… \
-d redirect_uri=http://127.0.0.1:53682/callback \
-d client_id=YOUR-CLIENT-ID \
-d code_verifier=THE-VERIFIER \
-d resource=https://www.pendingyou.com/v1
  • Every card.An app signed in this way has no computer, so it asks for cards:read and cards:read:all, and sees every card the person’s assistants ask them.
  • One sign-in per copy.Signing in again with the same key replaces the sign-in; another copy of your app, with its own key, keeps its own. A website’s sign-in replaces the person’s earlier one.
  • A website with a server.It exchanges its code with its client secret, and may skip DPoP: its tokens are then bearer tokens, which never leave its server.
With the SDKTypeScript
// A desktop app: the browser comes back to this computer.
import { fileStore, loopbackSignIn } from '@recordplane/pendingyou-sdk/node'
 
const signIn = await loopbackSignIn({
origin: 'https://www.pendingyou.com',
clientId: 'YOUR-CLIENT-ID',
scopes: ['cards:read', 'cards:read:all', 'cards:answer'],
store: fileStore(`${process.env.HOME}/.config/example-app/pendingyou.json`),
open: (url) => openInBrowser(url),
})
await signIn.signedIn
 
// A single-page app: away to Pending You, and back to its own page.
import { finishRedirectSignIn, redirectToSignIn } from '@recordplane/pendingyou-sdk'
 
await redirectToSignIn({ origin: 'https://www.pendingyou.com', clientId: 'YOUR-CLIENT-ID', redirectUri, scopes })
// …then on redirectUri's page:
await finishRedirectSignIn({ store })

Example:A desktop app’s sign-in through the browser, in a whole appexamples/answer-cards/sign-in.ts

DPoP

A device sign-in’s tokens, and a desktop or single-page app’s, are bound to your app’s key (RFC 9449), so a copy of them is useless without it. Send each request with a proof made for it.

A requestShell
curl https://www.pendingyou.com/v1/cards \
-H "Authorization: DPoP $ACCESS_TOKEN" \
-H "DPoP: $PROOF"
  • A new proof for every request.Its method and address (no query), the time, a random jti, and at /v1 the access token’s SHA-256 (ath). ES256 only.
  • Nonces.Every answer carries DPoP-Nonce: put the latest in your next proof. The token endpoint requires one; /v1 asks again with use_dpop_nonce when yours is old. Make the proof again with the new one and send the request once more.
dpop.jsTypeScript
// One key for the sign-in: keep its private JWK with the tokens, nowhere else.
const key = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', key.publicKey)
const jwk = { kty, crv, x, y }
 
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
const sha256 = async (text) => b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
const part = (value) => b64url(new TextEncoder().encode(JSON.stringify(value)))
 
// What the sign-in is bound to (the attestation's --cnf): RFC 7638.
const thumbprint = await sha256(JSON.stringify({ crv, kty, x, y }))
 
// A proof for each request: its method and address, now, the token's hash, and the last nonce.
async function proof(method, url, accessToken, nonce) {
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk }
const claims = {
jti: crypto.randomUUID(),
htm: method,
htu: url.split(/[?#]/)[0],
iat: Math.floor(Date.now() / 1000),
...(accessToken ? { ath: await sha256(accessToken) } : {}),
...(nonce ? { nonce } : {}),
}
const signing = `${part(header)}.${part(claims)}`
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key.privateKey,
new TextEncoder().encode(signing))
return `${signing}.${b64url(signature)}`
}

Scopes

Ask for what your app does, no more. The person may allow less: every card, not just one computer’s, is a tick that starts off.

ScopeWhat it allows
cards:readSee the cards the assistants on the signed-in computer ask the person.
cards:read:allSee every card, from all the person’s computers and assistants.
cards:answerAnswer cards when the person presses something, and take an answer back in its 5 seconds.
cards:replyWrite to the assistant that asked, on its card.
cards:laterPut cards in Later and bring them back.
cards:delegateHand a card to another of the person’s assistants, and take it back.
assistants:readSee which assistants are running, and in which terminal.
presence:deskKeep the person’s phone quiet while they’re using the app.

Endpoints

13 routes under https://www.pendingyou.com/v1, each with the scope it needs. Open one for its fields, its example and what it may refuse.

GET/v1/meWho and what this sign-in is

The person, their workspace, the app and this sign-in: its scopes, its reach (one computer or every card), its computer, and when it ends. Any scope.

Scope: anyETag

RequestText
GET /v1/me
200JSON
{
"user": {
"id": "usr_0a1b2c3d4e5f60718293"
},
"workspace": {
"id": "ws_0a1b2c3d4e5f60718293"
},
"app": {
"id": "app_00000000000000000002",
"name": "Pending You for Herdr",
"verified": true
},
"grant": {
"id": "apg_7d6c5b4a39281706f5e4",
"scopes": ["cards:read", "cards:answer", "presence:desk"],
"reach": "machine",
"machine": {
"id": "mch_1e2d3c4b5a6978877665",
"name": "build-01"
},
"dpop": true,
"createdAt": "2026-10-05T19:20:00.000Z",
"expiresAt": "2026-11-04T19:20:00.000Z"
}
}

May refuse with: invalid app_not_available rate_limited server_error unavailable

GET/v1/cardsThe cards in reach, one list at a time

Waiting on the person in the stack’s order (pending), in Later soonest back first (snoozed), with a helper (delegated), or done in the last 30 days newest first. Filters repeat (`?area=prj_…&area=prj_…`); pages follow `next`. The first `whole` cards come whole.

Scope: cards:readETag

Query
Field ? optionalTypeWhat it’s for
status?"pending" | "snoozed" | "delegated" | "done"
area?string[]
assistant?string[]
agent?string[]
app?("claude-code" | "codex" | "cursor" | "gemini-cli" | "opencode" | "pi" | "grok" | "muse" | "chatgpt" | "claude" | "dots" | "other" | "webhook")[]
machine?string[]
threadId?string
limit?integer
whole?integer
after?string
RequestText
GET /v1/cards?status=pending&limit=20
200JSON
{
"cards": [
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"version": 3,
"status": "pending",
"turn": "you",
"kind": "choice",
"title": "Which queue should the nightly sweeper use?",
"summary": "Two queues can take it. The old one is slower but already has alerts.",
"urgency": "today",
"blocking": true,
"options": [
{
"id": "a",
"label": "The new queue"
},
{
"id": "b",
"label": "The old queue"
}
],
"recommendedIds": ["a"],
"threadId": "req_4f1c9a0e2b7d6c5a8e31",

May refuse with: invalid app_not_available rate_limited server_error unavailable

GET/v1/cards/{id}One card, whole

A card out of reach is not found, exactly as one that doesn’t exist.

Scope: cards:readETag

RequestText
GET /v1/cards/req_4f1c9a0e2b7d6c5a8e31
200JSON
{
"card": {
"id": "req_4f1c9a0e2b7d6c5a8e31",
"…": "…"
}
}

May refuse with: invalid app_not_available not_found rate_limited server_error unavailable

POST/v1/cards/{id}/answerAnswer a card

With the version you showed the person. Its assistant hears it once its 5 seconds are over; until `undoUntil` this app may take it back.

Scope: cards:answerIdempotency-Key required

Body (JSON)
Field ? optionalTypeWhat it’s for
versioninteger
choiceIds?string[]
approved?boolean
text?string
note?object
textstring
via?"text" | "voice"
theyDecide?true
answers?map
failedStep?integer
askId?string
RequestText
POST /v1/cards/req_4f1c9a0e2b7d6c5a8e31/answer
Idempotency-Key: 6f1d2c9e-…
 
{
"version": 3,
"choiceIds": ["a"],
"note": {
"text": "Keep the alerts on."
}
}
200JSON
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"status": "answered",
"version": 4,
"undoUntil": "2026-10-06T14:05:05.000Z"
}

May refuse with: invalid app_not_available high_stakes area_change forbidden not_found version_conflict not_waiting delegated conflict idempotency_in_progress idempotency_mismatch rate_limited server_error not_available unavailable

POST/v1/cards/{id}/undoTake this app’s answer back

Within its 5 seconds, and only an answer given in this app.

Scope: cards:answer

RequestText
POST /v1/cards/req_4f1c9a0e2b7d6c5a8e31/undo
200JSON
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"status": "pending",
"version": 5
}

May refuse with: invalid app_not_available high_stakes not_your_answer forbidden not_found not_waiting hold_over conflict idempotency_in_progress idempotency_mismatch rate_limited server_error unavailable

POST/v1/cards/{id}/messagesWrite to the assistant that asked

The person’s words, on its card. They hand it the turn and it acts on them as the answer, so they count against the answers’ limit.

Scope: cards:reply

Body (JSON)
Field ? optionalTypeWhat it’s for
bodystring
via?"text" | "voice"
RequestText
POST /v1/cards/req_4f1c9a0e2b7d6c5a8e31/messages
 
{
"body": "Ask Sam first: it’s their queue."
}
200JSON
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"messageId": "msg_3c2b1a0f9e8d7c6b5a49",
"turn": "agent",
"version": 4
}

May refuse with: invalid app_not_available high_stakes forbidden not_found not_waiting delegated conflict idempotency_in_progress idempotency_mismatch rate_limited server_error unavailable

POST/v1/cards/{id}/laterPut a card in Later

Until a time (a minute to two months ahead) or a preset, `1h`, `tonight` or `tomorrow`, in `timeZone`.

Scope: cards:later

Body (JSON)
Field ? optionalTypeWhat it’s for
untilISO datetime | "1h" | "tonight" | "tomorrow"A time, or a preset: in an hour, tonight (6:00 PM) or tomorrow morning (8:00 AM), in timeZone.
timeZone?stringThe person’s IANA time zone, e.g. "America/Chicago", for the presets. Default UTC.
RequestText
POST /v1/cards/req_4f1c9a0e2b7d6c5a8e31/later
 
{
"until": "tomorrow",
"timeZone": "America/Chicago"
}
200JSON
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"status": "snoozed",
"until": "2026-10-07T13:00:00.000Z",
"version": 4
}

May refuse with: invalid app_not_available high_stakes forbidden not_found not_waiting delegated conflict idempotency_in_progress idempotency_mismatch rate_limited server_error unavailable

POST/v1/cards/{id}/backBring a card back from Later

Now, to its place in the stack. One already back answers with its version.

Scope: cards:later

RequestText
POST /v1/cards/req_4f1c9a0e2b7d6c5a8e31/back
200JSON
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"status": "pending",
"version": 5
}

May refuse with: invalid app_not_available high_stakes forbidden not_found not_waiting conflict idempotency_in_progress idempotency_mismatch rate_limited server_error unavailable

POST/v1/cards/{id}/delegateHand a card to another assistant

One of the person’s assistants in reach (GET /v1/assistants). `freely`: its answer goes straight back; `loop`: it comes to the person first. Until `undoUntil` the helper can’t see it.

Scope: cards:delegate

Body (JSON)
Field ? optionalTypeWhat it’s for
toobject
assistantIdstring
agentId?string
mode"freely" | "loop"
note?string
RequestText
POST /v1/cards/req_4f1c9a0e2b7d6c5a8e31/delegate
 
{
"to": {
"assistantId": "con_2b3c4d5e6f708192a3b4"
},
"mode": "loop",
"note": "Sam owns the queues."
}
200JSON
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"status": "delegated",
"version": 4,
"hears": "while-working",
"undoUntil": "2026-10-06T14:05:05.000Z"
}

May refuse with: invalid app_not_available high_stakes forbidden not_found not_waiting delegated conflict idempotency_in_progress idempotency_mismatch rate_limited server_error unavailable

POST/v1/cards/{id}/take-backTake a card back from its helper

Before the helper answers it: the card waits on the person again.

Scope: cards:delegate

RequestText
POST /v1/cards/req_4f1c9a0e2b7d6c5a8e31/take-back
200JSON
{
"id": "req_4f1c9a0e2b7d6c5a8e31",
"status": "pending",
"version": 5
}

May refuse with: invalid app_not_available high_stakes forbidden not_found not_waiting conflict idempotency_in_progress idempotency_mismatch rate_limited server_error unavailable

GET/v1/changesWhat changed since a cursor

Ids and types only, in commit order, kept to the reach. Without `after`: from now. With `wait` (up to 25 seconds) it waits for the first change. Read GET /v1/cards again on start and every 10 minutes too: a card that comes into reach makes no change.

Scope: cards:readETag

Query
Field ? optionalTypeWhat it’s for
after?string
wait?integer
limit?integer
RequestText
GET /v1/changes?after=djEuNzAwMC4xMg&wait=25
200JSON
{
"changes": [
{
"type": "card.created",
"cardId": "req_4f1c9a0e2b7d6c5a8e31",
"at": "2026-10-06T14:02:11.000Z"
}
],
"cursor": "djEuNzAxMi4w",
"more": false
}

May refuse with: invalid app_not_available rate_limited server_error unavailable

GET/v1/assistantsThe assistants in reach

Each sign-in, its computer and how it hears answers, and its agents: live, recent or quiet, their open cards, and the sessions running now with the terminal each runs in. Live ones first.

Scope: assistants:readETag

RequestText
GET /v1/assistants
200JSON
{
"assistants": [
{
"id": "con_9f8e7d6c5b4a39281706",
"app": "claude-code",
"appName": "Claude Code",
"name": "Claude Code on build-01",
"machine": {
"id": "mch_1e2d3c4b5a6978877665",
"name": "build-01"
},
"hears": "while-working",
"agents": [
{
"id": "agt_6a5b4c3d2e1f0a9b8c7d",
"name": "sweeper",
"state": "live",
"liveAt": "2026-10-06T14:04:40.000Z",
"openCards": 1,
"liveSessions": [
{
"appSessionId": "5b0c…",
"cwd": "/home/sam/code/sweeper",
"liveAt": "2026-10-06T14:04:40.000Z",

May refuse with: invalid app_not_available rate_limited server_error unavailable

POST/v1/presenceSay the person is at this app

`here: true` every 30 seconds while they’re active in it (it lasts 75), `here: false` when they leave. Meanwhile their phones stay quiet, as with Pending You open on a computer. Answers 204.

Scope: presence:desk

Body (JSON)
Field ? optionalTypeWhat it’s for
hereboolean
RequestText
POST /v1/presence
 
{
"here": true
}
AnswerText
204 No Content

May refuse with: invalid app_not_available rate_limited server_error unavailable

  • Reads have ETags.Send the ETag back as If-None-Match and a read that hasn’t changed answers 304.
  • Answers need an Idempotency-Key.A new key for each answer (every other change to a card takes one too). The same key and request within 24 hours get the first answer again, so a retry never answers twice.
  • Show the version you answer.An answer names the card’s version. If the card changed meanwhile, it’s a 409 version_conflict: show it again and ask.

Example:Showing a card and answering it from the person’s key pressexamples/answer-cards/answer.ts

The change feed

GET /v1/changes says what changed since a cursor, in commit order: ids and types only. Read the cards you show again.

TypeWhen
card.createdAn assistant asked something new.
card.answeredSomeone answered it: the person, here or elsewhere, or a helper.
card.changedAnything else: words, Later, handed over, back, updated.
card.closedIts assistant picked the answer up, withdrew it, or it ran out.
  • Wait for the next change.wait holds the request up to 25 seconds until something changes. Keep the cursor and pass it back as after; without one, the feed starts from now.
  • Read the cards again every 10 minutes.On start, and every 10 minutes, read GET /v1/cards too: a card that comes into reach makes no change of its own.

Example:Following the feed, and passing each change to your own serverexamples/change-feed/watch.ts

Errors

Refusals are problem details (RFC 9457), application/problem+json, with a stable code. Sign-in’s own refusals are OAuth’s, with WWW-Authenticate.

403 · application/problem+jsonJSON
{
"type": "https://www.pendingyou.com/docs/api/errors#high_stakes",
"title": "This card is high stakes",
"status": 403,
"code": "high_stakes",
"detail": "Answer it in Pending You: it spends money, can’t be undone or goes public.",
"requestId": "rid_0b5e8c1d2a3f4e5d6c7b",
"url": "https://www.pendingyou.com/app/r/req_4f1c9a0e2b7d6c5a8e31"
}
Every error

Limits

Past one: 429 rate_limited with retry-after. They’re per sign-in, and per person for all their apps together.

LimitA minuteCounts
Reads300Every GET
Writes60Every POST
Answers30Answers, and words on a card
Waits6GET /v1/changes that waits
Per person1,200Every call of every app

Versions

/v1 only adds: paths, optional fields, answer fields and new values. Ignore what you don’t know.

  • Changes in behaviour are dated.Send Pending-You-Version: 2026-10-05 to pin one; every answer says which it followed. Anything that can’t be added waits for /v2.
  • The OpenAPI document.OpenAPI 3.1 at /v1/openapi.json, made from the same definitions the API serves. Device sign-in and DPoP are in x-pendingyou-device and x-pendingyou-dpop.

The SDK

@recordplane/pendingyou-sdk does all of this for you: device sign-in, sign-in by code with PKCE, DPoP and nonces, refreshes, ETags, Idempotency-Keys and the change feed. No dependencies; Node 20 or later, browsers and Workers.

app.tsTypeScript
import { createClient, deviceSignIn } from '@recordplane/pendingyou-sdk'
import { cliAttester, fileStore } from '@recordplane/pendingyou-sdk/node'
 
const store = fileStore(`${process.env.HOME}/.config/example-app/pendingyou.json`)
const signIn = await deviceSignIn({
origin: 'https://www.pendingyou.com',
clientId: 'YOUR-CLIENT-ID',
scopes: ['cards:read', 'cards:answer'],
machine: 'build-01',
attest: cliAttester(),
store,
})
console.log(`Allow it at ${signIn.verificationUriComplete}`)
await signIn.signedIn
 
const pendingYou = createClient({ store })
for await (const batch of pendingYou.changes()) {
if (batch.resync) await showAll(await pendingYou.cards.list())
for (const change of batch.changes) await show(await pendingYou.cards.get(change.cardId))
}

Example:Every call Pending You for Herdr makes, as a terminal queueexamples/herdr-queue/

Example:Every example, with how to run itexamples/

Changelog

VersionWhat changed
2026-10-05The first version: the queue, answers and undo, words, Later, handing over, assistants, presence and the change feed.