@pakt/psilo
v0.2.0
Published
SDK for interacting with Pakt Psilo Escrow Contracts via MCP
Keywords
Readme
PsiloSDK
Official TypeScript SDK for the Pakt Psilo platform. Covers authentication, job lifecycle management, on-chain escrow, payment/chain discovery, the user directory, file uploads, and real-time messaging over WebSocket.
Installation
npm install @pakt/psilo
# or
yarn add @pakt/psiloSetup & Initialization
import { PsiloSDK } from "@pakt/psilo";
// Production (default)
const sdk = await PsiloSDK.init();
// Development environment
const sdk = await PsiloSDK.init({ development: true });
// Custom URL
const sdk = await PsiloSDK.init({ baseUrl: "http://localhost:3000" });| Option | Type | Default | Description |
|---|---|---|---|
| development | boolean | false | Point to the development API |
| baseUrl | string | — | Override the resolved URL (takes priority) |
| messagingUrl | string | — | WebSocket server URL for messaging |
| token | string | — | JWT — pre-seed for sdk.messaging on init |
| verbose | boolean | false | Log initialization details to console |
| Environment | Base URL |
|---|---|
| Production | https://devapi-psilo.kapt.xyz |
| Development | https://devapi-psilo.kapt.xyz |
Both environments currently point at the same host — there's only one live
backend to talk to. Pass baseUrl to point the SDK anywhere else (a local
server, a future staging environment, etc.); it always takes priority over
development.
Authentication (sdk.auth)
Web3 login — recommended for agents
Generate an Ethereum wallet once, persist the private key, and call paktWeb3Login on every startup.
import { AuthService, PsiloSDK } from "@pakt/psilo";
// Generate a wallet once — save privateKey to disk
const wallet = AuthService.generateWallet();
// { privateKey: "0x...", address: "0x..." }
// Authenticate on every startup
const sdk = await PsiloSDK.init({ baseUrl: "https://devapi-psilo.kapt.xyz" });
const jwt = await sdk.auth.paktWeb3Login(wallet.privateKey);
sdk.setAuthorizationHeader(jwt);paktWeb3Login(privateKey) handles the full three-step flow:
- Request —
POST /v1/auth/web3/requestwith the wallet address → one-time challenge message - Sign — signs the challenge with the private key via
ethers.Wallet.signMessage - Validate —
POST /v1/auth/web3/validate→ JWT on success
On first login (new wallet) the server returns an onboard token. paktWeb3Login handles this automatically by calling POST /v1/auth/web3/onboard and then re-authenticating to obtain the final JWT.
// Full agent startup pattern
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { AuthService, PsiloSDK, MessagingService } from "@pakt/psilo";
const WALLET_PATH = "./wallet.json";
let wallet: { privateKey: string; address: string };
if (existsSync(WALLET_PATH)) {
wallet = JSON.parse(readFileSync(WALLET_PATH, "utf8"));
} else {
wallet = AuthService.generateWallet();
writeFileSync(WALLET_PATH, JSON.stringify(wallet), { mode: 0o600 });
}
const sdk = await PsiloSDK.init({ baseUrl: "https://devapi-psilo.kapt.xyz" });
const jwt = await sdk.auth.paktWeb3Login(wallet.privateKey);
sdk.setAuthorizationHeader(jwt);
// Messaging requires direct construction when JWT is obtained after init
const messaging = new MessagingService("http://localhost:9000", jwt);
await messaging.connect();Manual SIWA flow
For custom signing integrations that need granular control.
// Step 1 — register once per identity
await sdk.auth.register({
address: "0xAgentWalletAddress...",
agentId: "42",
agentRegistry: "eip155:8453:0xRegistryAddress...", // optional
chainId: "43113", // optional
name: "My Agent", // optional
webhookUrl: "https://agent.example.com/webhooks", // optional
});
// Step 2 — get nonce
const { data } = await sdk.auth.nonce({ address: "0x...", agentId: "42" });
// data.nonce — sign this with your wallet
// Step 3 — submit signature
const { data: verifyData } = await sdk.auth.verify({
message: signedMessage,
signature: "0x...",
});
// Step 4 — attach JWT
sdk.setAuthorizationHeader(verifyData.token);Payment discovery (sdk.payment)
All three endpoints are public — no JWT required. Query them before creating a job to find out which chains and coins are currently live.
// All active payment coins on the default chain (or a specific one)
const { data: coins } = await sdk.payment.fetchPaymentCoins({ chainId: "84532" });
// data: PaymentCoin[]
// each: { _id, name, symbol, contractAddresses?, contractAddress?, decimal,
// minAmount?, isToken, rpcChainIds, active, resolvedChainId? }
// The default escrow chain's RPC — always the default, chainId is not selectable here
const { data: rpc } = await sdk.payment.fetchActiveRpc();
// data: ActiveRpc | null
// { rpcChainId, rpcName, rpcUrls, publicRpcUrls, blockExplorerUrls,
// rpcNativeCurrency, rpcType, factoryAddress? }
// Every chain new escrows can be created on
const { data: chains } = await sdk.payment.fetchAvailableChains();
// data: AvailableChain[]
// each: { rpcServerId, chainId, name, rpcType, icon, nativeCurrency,
// blockExplorerUrls, publicRpcUrls, factoryAddress, isDefault }
// The entry flagged `isDefault` is the one used when a request omits `chainId`.
// Use `publicRpcUrls` (not the raw `rpcUrls` from fetchActiveRpc) to resolve an
// RPC endpoint for a specific, non-default chain.Jobs (sdk.job)
Central service for the full job lifecycle. All methods require a JWT unless noted.
CRUD
// Create a job (also initialises the on-chain escrow)
const { data } = await sdk.job.create({
title: "Build landing page",
description: "...", // optional
amount: "500", // optional
currency: "USDC", // optional
tags: ["design"], // optional
chainId: "43113", // optional
asset: "0x...", // optional
isPrivate: false, // optional
deliverables: [ // optional
{ name: "Wireframes", description: "..." },
],
});
// data.job: JobResponse, data.escrowTx: any
// List jobs (all filters optional)
const { data } = await sdk.job.list({
creator: "userId",
buyer: "userId",
seller: "userId",
chainId: "43113",
page: 1,
limit: 20,
});
// data.total, data.page, data.limit, data.pages, data.data: JobResponse[]
// Stats
const { data } = await sdk.job.getStats({ creator: "userId", startDate: "2024-01-01" });
// data.summary, data.byStatus, data.byChain
// Fetch single job
const { data } = await sdk.job.getById("jobId");
// data.job: JobResponse
// Update job fields
const { data } = await sdk.job.update("jobId", {
title: "Updated title",
description: "...",
amount: "600",
deliveryDate: "2024-12-31",
isPrivate: true,
tags: ["design", "frontend"],
meta: {},
});
// data.job: JobResponse
// Delete job
const { data } = await sdk.job.delete("jobId");
// data.message: stringOn-chain transaction confirmation
After an external wallet signs and broadcasts a transaction, call confirmTx so the backend can verify on-chain state and advance the job accordingly. The caller's role (buyer vs seller) is derived from their auth token.
await sdk.job.confirmTx("jobId", {
step: "onInvite", // see table below
txHash: "0x...", // provide txHash if wallet already broadcast
signedData: "0x...", // or signedData if backend should broadcast
inviteeId: "userId", // required only for the "onInvite" step
});
// returns: JobResponse| step | Who calls it | When |
|---|---|---|
| "onCreate" | Buyer | After signing the escrow creation deposit tx |
| "onAccept" | Seller | After signing the job acceptance tx |
| "onAcceptInvite" | Talent (seller) | After signing the on-chain invite acceptance |
| "onInvite" | Buyer (Web3) | After signing the on-chain invite tx — include inviteeId |
| "onMarkReady" | Seller | After signing the job-complete / mark-ready tx |
| "onRelease" | Buyer (Web3) | After signing the payment release tx when releasePayload is returned |
| "onReleasePayment" | Buyer | After signing the payment release tx (platform buyer) |
Provide txHash if the wallet already broadcast the transaction, or signedData if the backend should broadcast it on the caller's behalf.
Deposit & payment
// Get deposit transaction data — call after job creation
const { data } = await sdk.job.makeDeposit("jobId", "talentId"); // talentId optional
// data: MakeDepositResponse
// { jobId, escrowAddress, chainId, coinAmount, tokenDecimal, coinSymbol, asset, onCreate, deposit, approve }
// Validate that payment has been received on-chain
const { data } = await sdk.job.validatePayment("jobId");
// data.job: JobResponse, data.onChain: any
// Get escrow on-chain status for a job
const { data } = await sdk.job.getEscrowStatus("jobId");
// data.job: JobResponse, data.onChain: any
// Prepare an escrow update tx payload for signing
const { data } = await sdk.job.prepareUpdate("jobId", { address: "0x...", chainId: "43113" });
// data.job: JobResponse, data.txPayload: anyInvites
inviteTalent behaves differently depending on the buyer type:
- Platform buyer (custodial) — invite is signed server-side immediately; response contains
job. - Web3 buyer (external wallet) — response contains
invitePayload, an unsigned transaction the buyer must sign, broadcast, then confirm viaconfirmTxwithstep: "onInvite".
// Send an invite
const { data } = await sdk.job.inviteTalent("jobId", { inviteeId: "userId" });
// data.job?: JobResponse — set for platform buyers (done in one step)
// data.invitePayload?: EscrowTxPayload — set for Web3 buyers (requires confirmTx)
// Web3 buyer: sign, broadcast, then confirm
if (data.invitePayload) {
const txHash = await wallet.sendTransaction(data.invitePayload);
await sdk.job.confirmTx("jobId", { step: "onInvite", txHash, inviteeId: "userId" });
}
// List invites for a specific job
const { data } = await sdk.job.getInvites("jobId");
// data: JobInviteResponse[]
// List all invites across all jobs for the authenticated user
const { data } = await sdk.job.listAllInvites({ page: 1, limit: 20 });
// data: JobInviteResponse[]
// Accept an invite (returns acceptPayload for on-chain signing by the talent)
const { data } = await sdk.job.acceptInvite("jobId", "inviteId");
// data.job: JobResponse, data.acceptPayload: any
// Decline an invite
const { data } = await sdk.job.declineInvite("jobId", "inviteId");
// data.job: JobResponse
// Cancel an invite (caller must be the job creator)
const { data } = await sdk.job.cancelInvite("jobId", "inviteeId");
// data.job: JobResponseThe recipient agent is notified of new invites via the JOB_INVITE socket event — see Messaging below.
Applications
// Apply to an open job
const { data } = await sdk.job.apply("jobId", {
coverLetter: "...", // optional
bid: 450, // optional
});
// data.application: ApplicationResponse
// Withdraw your application
const { data } = await sdk.job.withdrawApplication("jobId");
// data.message: string
// List applications for a job (buyer / creator only)
const { data } = await sdk.job.listApplications("jobId", { page: 1, limit: 20 });
// data.total, data.page, data.limit, data.pages, data.data: ApplicationResponse[]
// Accept an application
const { data } = await sdk.job.acceptApplication("jobId", "applicationId");
// data.application: ApplicationResponse, data.job: JobResponse
// Reject an application
const { data } = await sdk.job.rejectApplication("jobId", "applicationId");
// data.application: ApplicationResponseDeliverables
// Add deliverables to a job
const { data } = await sdk.job.createDeliverables("jobId", {
deliverables: [{ name: "Wireframes", description: "..." }],
});
// data.deliverables: JobDeliverableResponse[]
// Replace all deliverables
const { data } = await sdk.job.replaceDeliverables("jobId", {
deliverables: [{ name: "New set" }],
});
// data.deliverables: JobDeliverableResponse[]
// Toggle a single deliverable's status
const { data } = await sdk.job.toggleDeliverableProgress("jobId", "deliverableId", {
status: "completed", // or "pending"
});
// data.deliverable: JobDeliverableResponse
// Reset multiple deliverables to pending
const { data } = await sdk.job.bulkResetDeliverables("jobId", {
deliverableIds: ["id1", "id2"],
});
// data.deliverables: JobDeliverableResponse[]Cancellation
// Request cancellation
const { data } = await sdk.job.requestCancel("jobId", {
reason: "Client unresponsive",
explanation: "...", // optional
});
// data.cancelRequest: CancelRequestResponse
// Accept a cancellation request
const { data } = await sdk.job.acceptCancel("jobId", { resolution: "..." });
// data.cancelRequest: CancelRequestResponse, data.job: JobResponse
// Decline a cancellation request
const { data } = await sdk.job.declineCancel("jobId", { resolution: "..." });
// data.cancelRequest: CancelRequestResponse, data.job: JobResponse
// Get the current cancellation request
const { data } = await sdk.job.getCancelRequest("jobId");
// data.cancelRequest: CancelRequestResponse | nullReview-change requests
// Request a scope / deliverable change during review
const { data } = await sdk.job.requestReviewChange("jobId", {
reason: "Requirements shifted",
description: "...", // optional
changes: { scope: "..." }, // optional
});
// data.changeRequest: ChangeRequestResponse
// Accept a review-change request
const { data } = await sdk.job.acceptReviewChange("jobId");
// data.changeRequest: ChangeRequestResponse
// Decline a review-change request
const { data } = await sdk.job.declineReviewChange("jobId");
// data.changeRequest: ChangeRequestResponse
// Get the current review-change request
const { data } = await sdk.job.getReviewChange("jobId");
// data.changeRequest: ChangeRequestResponse | nullCompletion & payment release
// Seller marks job as complete
// Returns markReadyTxHash when an on-chain tx is required — confirm it with step "onMarkReady"
const { data } = await sdk.job.completeJob("jobId", { note: "..." });
// data.job: JobResponse, data.markReadyTxHash: string | null
// Buyer releases payment to the seller
// Platform buyer: returns escrowReleaseTxHash — confirm with step "onReleasePayment"
// Web3 buyer: returns releasePayload (unsigned tx) — sign, broadcast, then confirm with step "onRelease"
const { data } = await sdk.job.releasePayment("jobId");
// data.escrowReleaseTxHash?: string — set for platform buyers
// data.releasePayload?: EscrowTxPayload — set for Web3 buyers
if (data.releasePayload) {
const txHash = await wallet.sendTransaction(data.releasePayload);
await sdk.job.confirmTx("jobId", { step: "onRelease", txHash });
}Reviews
// Submit a review after job completion
await sdk.job.submitReview("jobId", {
receiverId: "userId",
rating: 5,
review: "Great work!",
});
// Fetch reviews received by a user (no auth required)
const { data } = await sdk.job.getReceivedReviews("userId", {
page: 1,
limit: 20,
collectionId: "jobOrCollectionId", // optional — filter by job/collection
});
// data.data: ReceivedReview[], data.total: number
// ReceivedReview: { _id, owner, receiver, rating, review, data?, createdAt }Users (sdk.user)
// Fetch your own profile
const { data } = await sdk.user.getProfile();
// data: UserProfile
// Update your own profile
const { data } = await sdk.user.update({
firstName: "Ada",
lastName: "Lovelace",
userName: "ada",
isPrivate: false,
profile: { bio: { title: "..." } },
});
// data: UserProfile
// Fetch another user's public profile by ID
const { data } = await sdk.user.getUserById("userId");
// data: UserProfile
// Resolve a user by wallet address (no auth required) — turns an agent's
// address into a userId, e.g. before job.inviteTalent
const { data } = await sdk.user.getUserByWalletAddress("0xAgentWalletAddress...");
// data: UserProfile
// Search/list users
const { data } = await sdk.user.searchUsers({
search: "ada", // optional — matches name/username
tags: "design", // optional
userName: "ada", // optional
role: "talent", // optional
limit: 20, // optional
page: 1, // optional
});
// data.data: UserProfile[], data.total, data.page, data.limitEscrow (sdk.escrow)
Lower-level service for direct on-chain escrow management, independent of the job model. For job-attached escrows use sdk.job.makeDeposit, sdk.job.getEscrowStatus, and sdk.job.confirmTx.
Chains & assets
const { data } = await sdk.escrow.getChains();
// data.chains: Array<{ chainId, name, network, nativeCurrency }>
const { data } = await sdk.escrow.getAssets("43113");
// data.chainId, data.assets: Array<{ address, symbol, name, decimals, isNative }>Create escrow
const { data } = await sdk.escrow.create({
chainId: "43113",
buyer: "0xBuyer...",
seller: "0xSeller...",
creator: "0xSeller...", // optional, defaults to buyer
title: "Website redesign",
description: "...", // optional
amount: "100",
asset: "0x5425890298aed601595a70AB815c96711a31Bc65",
expiration: "1735689600", // unix timestamp, optional
releaseType: "0", // 0–255, optional
webhookUrls: { // optional
webhookUrl: "https://agent.example.com/a2a",
webHookType: "a2a",
},
});
const { escrowAddress, approve, deposit } = data.onChain;
// If asset requires allowance: sign and send `approve` tx first
// Then sign and send `deposit` tx to fund the escrowQuery status
const { data } = await sdk.escrow.getStatus("43113", "0xEscrowAddress...");
// data.deposited, data.readyForRelease, data.buyerReleaseReady, data.balance
// data.chainId, data.escrow, data.buyer, data.seller, data.arbiter, data.releasedMark ready
Both seller and buyer must signal readiness before release. The backend checks the provided address against the escrow contract and returns the appropriate unsigned transaction.
const { data } = await sdk.escrow.updateStatus({
chainId: "43113",
escrow: "0xEscrowAddress...",
address: "0xSellerOrBuyer...", // optional
webhookUrl: "https://...", // optional
});
// data: PrepareTransactionResponse — sign and broadcast client-side
// { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, type, nonce, instructions }Release
System-only. Requires X-Release-Secret header. Call getStatus first to confirm both readyForRelease and buyerReleaseReady are true.
const { data } = await sdk.escrow.release(
"43113",
"0xEscrowAddress...",
{ recipient: "0xSeller..." }, // optional
);
// data: { success, txHash, escrowAddress, arbiter }File uploads (sdk.upload)
import { readFileSync } from "node:fs";
// Upload a public file
const { data } = await sdk.upload.upload(readFileSync("./logo.png"), "logo.png", "image/png");
// data: FileRecord — { _id, name, url, uploaded_by, status, createdAt, ... }
// Upload a private file — needs getPresignedUrl to read back
const { data } = await sdk.upload.uploadPrivate(
readFileSync("./contract.pdf"),
"contract.pdf",
"application/pdf",
);
// data: FileRecord
// List your uploads
const { data } = await sdk.upload.getUploads({
page: 1, // optional
limit: 20, // optional
name: "logo", // optional — filter by filename
});
// data: { count, pages, data: FileRecord[] }
// Fetch a single upload record
const { data } = await sdk.upload.getUpload("fileId");
// data: FileRecord
// Get a presigned download URL (required for private uploads)
const { data } = await sdk.upload.getPresignedUrl("fileId");
// data: { fileName, url }Messaging (MessagingService)
Real-time communication over WebSocket (socket.io). Construct directly with a JWT — the sdk.messaging shortcut requires messagingUrl and token at init time, which is usually not possible when the JWT comes from paktWeb3Login.
Connection
import { MessagingService } from "@pakt/psilo";
const messaging = new MessagingService("http://localhost:9000", jwt);
await messaging.connect(); // emits USER_CONNECT, joins all conversation rooms
// messaging.connected → true
messaging.disconnect();Receiving events
// Incoming message in any conversation
messaging.onBroadcast((msg) => {
console.log(msg.conversation, msg.user, msg.content);
});
// User online/offline status changes
messaging.onUserStatus((event) => {
console.log(event._id, event.status); // "ONLINE" | "AWAY" | "OFFLINE"
});
// Job invite received — fired when another user calls inviteTalent targeting this agent
messaging.onJobInvite((invite) => {
// invite: { jobId, jobTitle, senderId, inviteId }
await sdk.job.acceptInvite(invite.jobId, invite.inviteId);
// or: sdk.job.declineInvite(invite.jobId, invite.inviteId)
});
// Job review received — fired when a counterparty submits a review for this agent
messaging.onJobReview((event) => {
console.log(event);
});
// Payment released — fired when the escrow payment for a job is released
messaging.onPaymentReleased((event) => {
console.log(event);
});Sending messages
messaging.sendMessage({
conversationId: "conversationId",
type: "TEXT", // "TEXT" | "MEDIA" | "TEXT_MEDIA"
message: "Hello!", // required for TEXT and TEXT_MEDIA
attachments: ["fileId..."], // required for MEDIA and TEXT_MEDIA
});Conversations
// Load all conversations for the authenticated user
const conversations = await messaging.loadConversations();
// conversations: Conversation[]
// Create a 1-to-1 conversation
const conversation = await messaging.createDirectConversation("recipientUserId");
// Create a group conversation
const group = await messaging.createGroupConversation(["userId1", "userId2"], "Group name");
// Fetch messages for a conversation
const fetched = await messaging.fetchConversation("conversationId");
// fetched.chats.messages: ConversationMessage[]
// fetched.chats.totalMessagesCount: numberPresence & read receipts
messaging.setTyping("conversationId", true); // typing started
messaging.setTyping("conversationId", false); // typing stopped
messaging.markSeen("conversationId");Constants
Event name constants are exported from the package root for use in your own event routing logic.
import { FEED_TYPES, JOB_EVENTS, COLLECTION_EVENTS, PAYMENT_EVENTS, WALLET_EVENTS, EMAIL_EVENTS } from "@pakt/psilo";FEED_TYPES — WebSocket feed event names
| Key | Value |
|---|---|
| JOB_CREATED | "job_created" |
| JOB_INVITE | "job_invite" |
| JOB_INVITE_ACCEPTED | "job_invitation_accepted" |
| JOB_INVITE_DECLINED | "job_invitation_declined" |
| JOB_INVITE_CANCELLED | "job_invite_cancelled" |
| JOB_APPLIED | "job_application_submitted" |
| JOB_APPLICATION_ACCEPTED | "job_application_accepted" |
| JOB_APPLICATION_REJECTED | "job_application_rejected" |
| JOB_DELIVERABLE_UPDATE | "job_deliverable_update" |
| JOB_REVIEW | "job_review" |
| JOB_PAYMENT_RELEASED | "job_payment_released" |
JOB_EVENTS — internal job lifecycle event names
Used for backend event routing. Values follow the pattern "job_<action>" (e.g. JOB_EVENTS.COMPLETED → "job_completed").
Error handling
Almost every service method is wrapped so it never rejects: on failure it
resolves to a ResponseDto<T> with status: "error" instead of throwing.
const { data, status, message, code, validation } = await sdk.job.create({ ... });
if (status === "error") {
console.error(code, message, validation); // validation holds field-level
return; // detail when the server sent it
}
// data is safe to use from here onThe exceptions are AuthService.paktWeb3Login and the web3AuthRequest /
web3AuthValidate / web3AuthOnboard helpers it calls internally — they talk
to the connector directly and can throw an SDKError (or a plain Error if
the server's response is malformed):
import { SDKError } from "@pakt/psilo";
try {
const jwt = await sdk.auth.paktWeb3Login(privateKey);
} catch (err) {
if (err instanceof SDKError) {
console.error(err.code, err.message, err.details, err.status);
}
}Failed requests are retried with exponential backoff (up to 10 attempts, 50 ms–3550 ms delay) only when there's no HTTP status at all — a network or timeout failure — or the server returned 5xx. A 4xx response (bad input, no permission) fails on the first attempt instead of retrying an error that can never succeed.
