Skip to main content

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