TypeScript SDK
@datalayer/personal-agent-protocol provides portable discovery, validation,
Reactor integration, and PAP session primitives.
The generated TypeScript API reference inventories the
portable entry and the explicit ./server subpath from their source exports.
Install and buildโ
npm install @datalayer/personal-agent-protocol
The package is ESM and publishes TypeScript declarations.
Incoming protocol JSON is decoded with parseJsonStrict, which rejects
duplicate member names at every depth, including names that become equal only
after JSON escape decoding. Discovery uses this parser automatically before
wire-model validation.
Discoveryโ
import {
buildPapReactor,
discoverCompany,
} from "@datalayer/personal-agent-protocol";
const reactor = buildPapReactor({
assertSafeUrl: async (url) => serverDnsPolicy.assertSafe(url),
});
const company = await discoverCompany("example.com", { reactor });
All network operations accept an AbortSignal. Pass it to discovery when the
calling task, request, or UI owns cancellation:
const controller = new AbortController();
const company = await discoverCompany("example.com", {
reactor,
signal: controller.signal,
});
The published portable ESM entry bundles Reactor's framework-neutral core so it
can run directly in Node and in browser bundlers. Company assertion signing,
proof verification, replay-cache contracts, and Node network policy are
exported only by the explicit ./server entry. Platform signing keys must
remain on a server or KMS; they must never be shipped in a browser or desktop
bundle.
The SDK exports the same contribution points as Python (Clocks,
RandomSources, Signers, DpopProofProviders, TokenStores, Caches,
BrowserHandoffs, BrowserFormPosts, PairwiseUserIdStores,
AuthorizationStateStores, and the rest; see Contribution
points).
Security-dependent services have no fallback and resolution fails closed when
missing or ambiguous.
BrowserSessionClient builds a maximum-60-second assertion and delegates its
top-level, body-only POST to BrowserFormPosts. Company code imports
verifyBrowserAssertion from @datalayer/personal-agent-protocol/server, so
public-key verification and replay storage stay outside portable browser
bundles. See the Browser Session flow.
DpopFetchClient owns Authorization and DPoP headers for API and
conversation requests. It generates a fresh proof for every nonce retry and
same-origin redirect target, and rejects cross-origin redirects before fetch
can forward a credential. See the DPoP transport flow.
ConversationClient composes that transport to start and continue PAP
conversations. generateMessageId() uses Reactor's cryptographic random source
to create a user-global idempotency key. Retain the complete message and reuse
it unchanged when retrying an ambiguous request. The client parses immediate
receipts or waited event pages and propagates AbortSignal without exposing
credentials. readEvents() and events() add ordered JSON long polling,
backlog draining, cursor-expiry recovery, and duplicate suppression. See
Conversation messages and
Conversation events.
Agent identityโ
AgentIdentityClient gives companies the same hardened metadata and JWKS
boundary as the Python SDK:
import {
AgentIdentityClient,
buildPapReactor,
} from "@datalayer/personal-agent-protocol";
const identity = new AgentIdentityClient({ reactor });
const metadata = await identity.fetchMetadata(
"https://agent.example/agent.json",
{ signal: controller.signal },
);
const jwks = await identity.fetchJwks(metadata.client_id, {
signal: controller.signal,
});
Client metadata is fetched without redirects and its client_id must equal
the exact fetched URL. The client validates same-domain redirect and JWKS URLs,
bounds responses, rejects duplicate JSON members, and retrieves keys through
the configured Reactor transport and URL-safety policy. JWKS redirects cannot
leave the client host. Only complete public P-256/ES256 and RSA/RS256 signing
keys are accepted; symmetric and private JWK members are rejected.
Session primitivesโ
import {
SessionGrant,
buildSessionAssertionClaims,
parseSessionToken,
resolvePairwiseUserId,
} from "@datalayer/personal-agent-protocol";
const pairwiseUserId = await resolvePairwiseUserId(reactor, {
tenantId: "application-42",
localUserId: "local-user-42",
companyIssuer: "https://auth.example.com",
});
const claims = buildSessionAssertionClaims({
clientId: "https://agent.example/agent.json",
pairwiseUserId,
tokenEndpoint: "https://auth.example.com/oauth/token",
});
// Signing is intentionally provided by a server or key-custody integration.
const grant = new SessionGrant({
assertion: "signed-session-assertion",
clientId: claims.iss,
clientAssertion: "signed-client-assertion",
});
const token = parseSessionToken(await response.json());
console.log(token.sessionId, token.scopes);
SecretValue renders as [REDACTED] in strings and JSON. Calling reveal()
is an explicit boundary for authorization and token requests; keep that value
out of logs, UI state, analytics, and model-visible tools.
SessionClient implements signed-out session start and renewal over fetch.
It requires host-provided signer and DPoP contributions, supports
AbortSignal, retries a DPoP nonce challenge once with fresh proofs, and
rejects redirects and malformed token responses. See
Signed-out sessions for usage and the exact security
boundary.
JoseDpopProofProvider is available from the portable entry for trusted
device-side proof creation. JoseSigner, verifyDpopProof, and their
server-side contracts are imported from
@datalayer/personal-agent-protocol/server. They use the maintained jose
package and Web Crypto. Only ES256 and RS256 are accepted; ES256 is the default.
See JOSE and DPoP for key and replay-cache requirements.
DirectSignInClient implements browser handoff, atomic one-use state,
proof-bound exchange, one DPoP nonce retry, cancellation, existing-Session
binding, and partial-scope reporting. The package also retains the lower-level
buildDirectSignInRequest, validateDirectSignInCallback, and
AuthorizationCodeGrant primitives. See Direct Sign-In
for usage and the trusted-host boundary.
Client, conversation, and Operations modelsโ
Use parseClientMetadata, parseJsonWebKeySet, parseMessage,
parseConversationEvent, parseConversationEventPage, and parseOperation
at trust boundaries. These parsers preserve unknown compatible fields and
future event types while enforcing the required shapes of known draft 0.1 and
Operations v1 objects.
The models are aligned with Python through shared fixtures. Conversation
creation, continuation, JSON event polling, SSE streaming, handoff, Direct
Conversations, and closing are implemented in ConversationClient, and
OperationsClient confirms operations (see the Operations guide).
Parsing an object alone does not perform durable cursor persistence,
approval, or confirmation.
Server-side URL safetyโ
The portable default rejects obvious local and private literal addresses. It cannot resolve DNS without importing platform-specific APIs into browser bundles. Node services can use the separate server entry:
import { buildPapReactor } from "@datalayer/personal-agent-protocol";
import {
JoseSigner,
NodeUrlSafetyPolicy,
createResolvingFetch,
verifyDpopProof,
} from "@datalayer/personal-agent-protocol/server";
const urlPolicy = new NodeUrlSafetyPolicy();
const reactor = buildPapReactor({
fetch: createResolvingFetch(),
assertSafeUrl: (url) => urlPolicy.assertSafe(url),
});
NodeUrlSafetyPolicy resolves every A and AAAA result and rejects the URL if
any address is loopback, private, link-local, shared, documentation-only,
multicast, or reserved. Literal IP addresses use the same classification
without DNS. Private-network access is disabled by default and must be enabled
explicitly for a controlled deployment.
NodeUrlSafetyPolicy alone is a DNS preflight, not connection pinning:
createResolvingFetch resolves and vets again for each request and dials only
the vetted address, keeping the URL's name for SNI, certificate checks, and
Host, so a name that rebinds after the check reaches no private address. The
Node company (buildCompany) uses it unless given its own fetch.
OpenAPI and MCPโ
ApiClient calls a company's OpenAPI API through DpopFetchClient, as Python's
ApiClient does. A read answers its JSON body; a consequential call answers
202 with an operation, proposed and not performed until it is confirmed with
OperationsClient. A refusal is an ApiError carrying the company's code only:
sign_in_required and insufficient_scope say in details.scope which scope
signing in would give, and a 429 says in details.retry_after how long to
wait (it is never retried for a write).
import { ApiClient, DpopFetchClient } from "@datalayer/personal-agent-protocol";
const api = new ApiClient({ transport: new DpopFetchClient({ reactor }) });
const orders = await api.call("GET", "https://shop.example/api/orders", {
token: session,
tenantId,
pairwiseUserId,
issuer,
});
const proposed = await api.call(
"POST",
"https://shop.example/api/orders/ord_7Hk2/exchanges",
{ token: session, tenantId, pairwiseUserId, issuer, body: {} },
);
console.log(proposed.status, proposed.operation?.summary);
MCP authorization uses Bearer tokens, so for MCP the personal agent asks for a
Session Token without a DPoP proof, with resource set to the MCP server's
url from discovery (ยง4.3): AccountTokenClient.bearerForMcp signed in (the
Account Token rotates as on any refresh), or SessionClient.bearerForMcp
signed out. The resource must be an HTTPS URL without a fragment, and the
token the company returns must be a Bearer token for the same Session. A
Bearer token works for anyone who holds it, so McpBearerAuth.fetch sends it
only to that exact URL (scheme, host, port and path) and refuses every other
request before it is made (bearer_not_for_this_url); DpopFetchClient
refuses it too. McpClient is a minimal Streamable HTTP client (initialize,
listTools, callTool) over it; a tool that proposes an operation returns it
as result.operation, the same Operation as an API's 202:
import { AccountTokenClient, McpClient, mcpApi } from "@datalayer/personal-agent-protocol";
const mcpUrl = mcpApi(company.document)!.url;
const accounts = new AccountTokenClient({ reactor });
const bearer = await accounts.bearerForMcp(accountTokenReference, {
context: { tokenEndpoint, issuer, clientId, tenantId, pairwiseUserId },
sessionId: session.sessionId,
resource: mcpUrl,
});
const mcp = new McpClient({ token: bearer.token!, url: mcpUrl });
const tools = await mcp.listTools();
const result = await mcp.callTool("exchange_jacket");
console.log(tools.length, result.operation?.operation_id);
To use another MCP client, hand it new McpBearerAuth(token, mcpUrl).fetch.
An MCP refusal is an McpError: the token error from the Bearer challenge
(sign_in_required with details.scope, invalid_token), or
extension_required with details.extension when the agent did not
negotiate Operations. operationFrom reads the one operation from an API body,
an MCP result or a message, and operationPage checks an operation's url is
on the company's domain before the agent opens it in its own browser.
Browser sessions at the companyโ
The TypeScript reference company serves the website as the Python one does
when its configuration sets web: true: discovery lists
web.browser_session_endpoint, a verified browser assertion (first use only,
from a live Session of the same agent and user) answers 303 to its
return_to with the company's own __Host-poppy_session cookie (Secure,
HttpOnly, SameSite=Lax, no lifetime of its own, valid only while the
Session is), and each operation's url shows it in that Session with a form
to confirm or cancel it: the same operation, performed at most once whichever
channel confirms it. A POST from another site, or without the page's CSRF
token, decides nothing.
import { CompanyConfig } from "@datalayer/personal-agent-protocol/server";
const config = new CompanyConfig({
name: "Example Shop",
domain: "shop.example",
scopes: ["orders:read", "orders:write"],
writeScopes: ["orders:write"],
web: true,
});
console.log(config.browserSessionEndpoint); // https://shop.example/poppy/browser-session