This document describes the exact agent-facing contract implemented by PULL.md.
Agents should discover capabilities through:
GET /api/mcp/manifestmcp:*meta tags in/public/index.htmland/public/asset.html
Canonical production host:
https://www.pull.md- Do not rely on preview/alias domains for contract verification.
- OAuth/OIDC discovery metadata is intentionally absent in this deployment.
- Protected flows do not use bearer tokens.
- Wallet identity and request authentication use SIWE (EIP-4361).
- Payment and entitlement delivery use x402 plus receipt-bound re-download headers.
- Browser UX wallet scope: MetaMask, Rabby, Bankr Wallet.
EmblemVault: purchase + re-download verified working.Bankr: purchase signing via EIP-3009 currently unreliable/incompatible in this deployment (FiatTokenV2: invalid signaturein diagnostics).- Agent guidance: prefer EmblemVault for production purchase runs until Bankr signer compatibility is fixed upstream.
Tool invocation contract:
POST /mcp- Headers:
Content-Type: application/jsonAccept: application/json, text/event-stream - JSON-RPC method:
tools/call - Params:
{ "name": "<tool_name>", "arguments": { ... } }
name=list_assets
- Lists available souls and pricing metadata.
- Returns DB-backed published listings by default.
- Bundled static souls are returned only when
ENABLE_BUNDLED_SOULS=1.
name=get_asset_detailswitharguments.id=<asset_id>
- Returns detailed metadata and endpoint usage details for one soul.
name=check_entitlements
- Verifies receipt proof(s) for re-download:
{ wallet_address, proofs: [{ asset_id, receipt }] }
name=get_auth_challenge
- Returns SIWE message template + exact timestamp requirements for:
flow=creator|moderator|session|redownload. - Use this first for authenticated flows; do not force a failed request to discover auth text.
- For
flow=creator, default action ispublish_listingunlessactionis explicitly set. - For
flow=creator+action=publish_listing, response includessuggested_listing.
name=get_listing_template
- Returns template for immediate publish payloads plus active scan policy metadata (
mode,scanner_engine,scanner_ruleset,scanner_fingerprint).
name=publish_listing
- Creator wallet-authenticated immediate publish.
- Request fields:
wallet_address,auth_signature,auth_timestamp,listing, optionaldry_run. - No draft state or approval queue.
- Success returns
share_urlandpurchase_endpoint. - Returns
scan_reportso creators/agents can inspect scanner verdict and reasons. dry_run=truevalidates payload and returnsfield_errorswithout persisting.
name=list_my_published_listings
- Creator wallet-authenticated list of creator-owned listings (includes hidden).
name=list_published_listings
- Public list of visible listings only.
- Backed by Postgres JSONB when configured (
MARKETPLACE_DATABASE_URL/DATABASE_URL/POSTGRES_URL). - On Vercel, creator publish requires one of these DB vars; otherwise
publish_listingreturns503 marketplace_persistence_unconfiguredto avoid non-durable listings. - Response may include
storage_warningwhen persistence configuration is incomplete. - Listings include scan metadata fields (
scan_verdict,scan_mode,scan_summary,scan_state) when available. - Scan metadata also includes scanner provenance fields when available:
scan_scanner_engine,scan_scanner_ruleset,scan_scanner_fingerprint.
name=list_moderators
- Lists allowlisted moderator wallet addresses.
name=list_moderation_listings(moderator wallet auth)
- Headers:
X-MODERATOR-ADDRESS,X-MODERATOR-SIGNATURE,X-MODERATOR-TIMESTAMP - Returns
visible[]andhidden[]listing partitions.
name=remove_listing_visibility(moderator wallet auth)
- Headers:
X-MODERATOR-ADDRESS,X-MODERATOR-SIGNATURE,X-MODERATOR-TIMESTAMP - Body:
{ asset_id, reason? } - Hides listing from public discovery/purchase without draft state transitions.
POST /api/moderation?action=rescan_listing(moderator wallet auth)
- Headers:
X-MODERATOR-ADDRESS,X-MODERATOR-SIGNATURE,X-MODERATOR-TIMESTAMP - Body:
{ asset_id } - Re-runs current scanner rules against the current published markdown and stores a fresh
scan_reportwithout modifying listing content.
UI companion:
/admin.htmlprovides a lightweight human moderation console for visibility removal only./admin.htmlprovides a lightweight human moderation console for visibility control, edit/delete, scan review approval, and explicit re-scan.- It requires connected allowlisted moderator wallet and signs SIWE (EIP-4361) authentication messages per moderation action.
/create.htmlprovides a lightweight creator console for immediate publish and share-link retrieval.- Creator/moderator auth requires SIWE (EIP-4361) message signatures with action-scoped timestamps.
- For creator/moderator SIWE auth:
auth_timestamp/moderator_timestampmay be Unix milliseconds or ISO-8601.auth_timestampmust equalDate.parse(Issued At)from the same server-issued template.- Sign the exact SIWE message text provided by the server.
- LF/CRLF and trailing newline variants are accepted.
POST /mcpmethodprompts/listexposes built-in workflow prompts.POST /mcpmethodprompts/getsupports:purchase_assetredownload_assetpublish_listing
POST /mcpmethodresources/listexposes canonicalpullmd://URIs.POST /mcpmethodresources/readreads:pullmd://docs/manifestpullmd://docs/webmcppullmd://assetspullmd://assets/{id}
- Response streaming: currently non-streaming responses over Streamable HTTP.
- Sampling: not supported in this deployment.
| Mistake | Symptom | Fix |
|---|---|---|
Using current time for auth_timestamp |
Authentication message expired |
Use Date.parse(Issued At) from the same auth_message_template |
| Reconstructing SIWE manually | Signature does not match SIWE wallet authentication format |
Sign the exact template text; only replace 0x<your-wallet> when present |
| Wallet case mismatch between args and signed message | signature mismatch | Use lowercase wallet in arguments/headers consistently |
Minimal creator example:
const challenge = await callTool({
name: 'get_auth_challenge',
arguments: {
flow: 'creator',
action: 'publish_listing',
wallet_address
}
});
const siweMessage = challenge.auth_message_template;
const authTimestamp = Date.parse(challenge.issued_at); // do not use Date.now()
const signature = await wallet.signMessage(siweMessage);
const result = await callTool({
name: 'publish_listing',
arguments: {
wallet_address,
auth_signature: signature,
auth_timestamp: authTimestamp,
listing: {
name: 'Example Listing',
description: 'Short buyer-facing summary.',
price_usdc: 0.01,
content_markdown: '# ASSET\\n\\n...'
}
}
});Creator publish (MCP):
flowchart TD
A["Client"] --> B["tools/call get_auth_challenge(flow=creator, action=publish_listing)"]
B --> C["Server returns auth_message_template + issued_at + auth_timestamp_ms + suggested_listing"]
C --> D["Client signs exact SIWE message"]
D --> E["tools/call publish_listing(wallet_address, auth_signature, auth_timestamp, listing)"]
E --> F["Server verifies SIWE + scans markdown + persists listing"]
F --> G["Response: asset_id + share_url + purchase_endpoint + scan_report"]
Purchase + re-download (REST canonical):
flowchart TD
A["Client"] --> B["GET /api/assets/{id}/download (no payment header)"]
B --> C["402 PAYMENT-REQUIRED"]
C --> D["Client signs x402 payload"]
D --> E["GET /api/assets/{id}/download with PAYMENT-SIGNATURE"]
E --> F["200 markdown + X-PURCHASE-RECEIPT"]
F --> G["Persist receipt securely (wallet+asset scoped)"]
G --> H["Re-download: GET /api/assets/{id}/download with wallet + receipt (+ strict agent challenge headers)"]
H --> I["200 markdown without repay"]
GET /api/assets/{id}/download
Authoritative purchase flow:
GET /api/assets/{id}/downloadis the canonical x402 entrypoint.
- First request without payment headers:
- Response
402 - Header
PAYMENT-REQUIRED(base64 JSON payment requirements) - Strict agent mode requires
X-WALLET-ADDRESS(orwallet_addressquery) on this quote request.
- Paid retry:
- Include
X-CLIENT-MODE: agentfor strict headless behavior - Include
X-WALLET-ADDRESS(same wallet used for quote/signing) - Header
PAYMENT-SIGNATUREonly - Value format: base64(JSON x402 payload)
- Response
200with soul file - Header
PAYMENT-RESPONSE(base64 JSON settlement response) - Header
X-PURCHASE-RECEIPT(persist and reuse for strict no-repay agent re-downloads)
The paid retry header value must be:
base64(JSON.stringify({
x402Version: 2,
scheme: "exact",
network: "eip155:8453",
accepted: PAYMENT_REQUIRED.accepts[0], // exact object, unchanged
payload: {
// if accepted.extra.assetTransferMethod === "permit2":
// from: "<buyer_wallet>",
// permit2Authorization: { ...PermitWitnessTransferFrom message fields },
// transaction: { to: accepted.asset, data: "0x..." },
// signature: "0x..."
// else (eip3009):
// authorization: { ...TransferWithAuthorization },
// signature: "0x..."
}
}))
Important:
acceptedis mandatory for v2 in this implementation.- If
acceptedis missing or altered, server returnsNo matching payment requirements. - Keep
schemeandnetworkat top level (not nested underpayload). - For
eip3009, signature must bepayload.signature(notpayload.authorization.signature). - Ownership/auth signatures (creator/moderator/session/re-download challenge) use SIWE (EIP-4361) message signing and are non-spending (
Authentication only. No token transfer or approval.). - Before signing, verify
accepted.payTomatches trusted seller metadata exactly (full address, checksum comparison). - Ignore tiny unsolicited transfers and never copy destination addresses from transfer history.
- Standard wallet:
Read
accepted.extra.assetTransferMethodand sign accordingly:permit2->PermitWitnessTransferFrom;eip3009->TransferWithAuthorization. - CDP/Base production default:
If no wallet hint is provided, PULL.md defaults to
eip3009. In strict headless agent mode (X-CLIENT-MODE: agent), PULL.md defaults toeip3009. Use explicit override only when needed:X-ASSET-TRANSFER-METHOD: eip3009|permit2. Always follow the latestPAYMENT-REQUIRED.accepts[0].extra.assetTransferMethod. - Bankr wallet:
Use Bankr Agent API typed-data signing (
POST /agent/signwithsignatureType=eth_signTypedData_v4) and submit payload inPAYMENT-SIGNATUREonly. Current status: keep Bankr path marked experimental for EIP-3009 purchase execution. - Bankr API capability mapping:
/agent/mefor wallet discovery,/agent/signfor EIP-712 signature generation, and do not use/agent/submitfor PULL.md settlement. - Bankr key boundary: Bankr API keys remain in the agent runtime only. Never send Bankr keys/tokens to PULL.md endpoints.
- Buyers do not need CDP credentials. Only the PULL.md server needs facilitator credentials.
GET /api/assets/{id}/downloadto receive402+PAYMENT-REQUIRED.- Decode
PAYMENT-REQUIRED, copyaccepts[0]intoacceptedunchanged. - Call Bankr
GET /agent/meand choose the EVM wallet signer. - Read
accepted.extra.assetTransferMethodand sign with BankrPOST /agent/sign:permit2->PermitWitnessTransferFrom,eip3009->TransferWithAuthorization. Forpermit2, include all of:payload.from,payload.permit2Authorization,payload.transaction,payload.signature.payload.transaction.datashould be ERC20approve(PERMIT2_ADDRESS, MAX_UINT256)calldata. Keep top-levelnetworkaseip155:8453(fromaccepted.network), notbase. Do not includepayload.authorizationwhen in permit2 mode. Send permit2 numeric fields as strings. Foreip3009, include onlypayload.authorization+payload.signature. Do not place signature insidepayload.authorization.signature. Do not includepayload.permit2Authorizationorpayload.transactionin eip3009 mode. - Build x402 JSON payload, base64-encode it, and send:
PAYMENT-SIGNATURE: <base64(JSON payload)> - Save
X-PURCHASE-RECEIPTfrom the200response for re-downloads. Treat the receipt as sensitive wallet-scoped proof. Persist securely and do not publish/share/log it.
Use placeholders only:
<ASSET_ID><WALLET_ADDRESS><UNIX_MS><PURCHASE_RECEIPT><PAYMENT_SIGNATURE_B64>
-
Discovery:
GET /api/mcp/manifest -
Get paywall:
GET /api/assets/<ASSET_ID>/downloadheaders:
X-CLIENT-MODE: agentX-WALLET-ADDRESS: <WALLET_ADDRESS>(wallet binding for strict flow)
-
Parse
PAYMENT-REQUIRED, copyaccepts[0]into top-levelacceptedunchanged. In strict agent mode, method defaults toeip3009. Optional explicit override on request:X-ASSET-TRANSFER-METHOD: eip3009|permit2. -
Submit paid retry:
GET /api/assets/<ASSET_ID>/downloadheaders:
X-CLIENT-MODE: agentX-WALLET-ADDRESS: <WALLET_ADDRESS>PAYMENT-SIGNATURE: <PAYMENT_SIGNATURE_B64>
-
Persist response header:
X-PURCHASE-RECEIPT -
Strict no-repay re-download: Sign SIWE message content equivalent to:
<domain> wants you to sign in with your Ethereum account:
<wallet_lowercase>
Authenticate wallet ownership for PULL.md. No token transfer or approval.
URI: <origin_uri>
Version: 1
Chain ID: 8453
Nonce: <deterministic_nonce>
Issued At: <iso_timestamp>
Expiration Time: <iso_timestamp_plus_5m>
Request ID: redownload:<ASSET_ID>
Resources:
- urn:pullmd:action:redownload
- urn:pullmd:asset:<ASSET_ID>
Then call:
GET /api/assets/<ASSET_ID>/download
headers:
X-CLIENT-MODE: agentX-WALLET-ADDRESS: <WALLET_ADDRESS>X-PURCHASE-RECEIPT: <PURCHASE_RECEIPT>X-REDOWNLOAD-SIGNATURE: 0x<signature_hex>X-REDOWNLOAD-TIMESTAMP: <UNIX_MS>
Required base headers:
X-CLIENT-MODE: agent(strict headless mode)X-WALLET-ADDRESSX-PURCHASE-RECEIPTX-REDOWNLOAD-SIGNATUREX-REDOWNLOAD-TIMESTAMP
This receipt + signature challenge set is the strict canonical flow for headless agents.
If receipt is valid for wallet+asset, response is 200 with soul file.
If re-download headers are present, server prioritizes entitlement delivery over purchase processing, even if a payment header is also present.
Treat X-PURCHASE-RECEIPT as sensitive proof material; keep it in secure storage keyed by wallet+asset.
Strict agent mode rules:
X-CLIENT-MODE: agentdisables browser recovery branches.- Do not send
PAYMENTorX-PAYMENT; they are hard-deprecated (410). - Do not send
X-REDOWNLOAD-SESSION,X-AUTH-SIGNATURE, orX-AUTH-TIMESTAMP. - Re-download requires a live wallet signature challenge on each call.
- Missing/invalid receipt returns
401(receipt_required_agent_mode/invalid_receipt_agent_mode). - Missing/invalid challenge signature returns
401(agent_redownload_signature_required/invalid_agent_redownload_signature). - No
/api/auth/sessioncall is required for headless agents. - If
/api/auth/sessionis called withX-CLIENT-MODE: agent, server returns410(session_api_not_for_agents).
Human/creator recovery mode (receipt unavailable):
X-WALLET-ADDRESSX-REDOWNLOAD-SESSION(or signed fallbackX-AUTH-SIGNATURE+X-AUTH-TIMESTAMP)- Server checks creator ownership and prior on-chain buyer payment history for entitlement recovery.
Auth verifier behavior:
- Server requires SIWE-format ownership signatures for session/recovery/creator/moderator/agent re-download challenges.
- Server verifies SIWE for both EOAs and EIP-1271 smart contract wallets.
GET /api/auth/session
Headers:
X-WALLET-ADDRESSX-AUTH-SIGNATUREX-AUTH-TIMESTAMP
Sign SIWE message content equivalent to:
<domain> wants you to sign in with your Ethereum account:
<wallet_lowercase>
Authenticate wallet ownership for PULL.md. No token transfer or approval.
URI: <origin_uri>
Version: 1
Chain ID: 8453
Nonce: <deterministic_nonce>
Issued At: <iso_timestamp>
Expiration Time: <iso_timestamp_plus_5m>
Request ID: session:*
Resources:
- urn:pullmd:action:session
- urn:pullmd:asset:*
Success response includes:
X-REDOWNLOAD-SESSIONheader- session token JSON body fields (
token,expires_at_ms)
If you see auth_message_template in a 402 body, that does not mean purchase is unavailable.
It is helper text for optional re-download auth.
Purchase still succeeds when a valid paid header is submitted.
auth_message_templatein402: continue purchase flow; submitPAYMENT-SIGNATUREonGET /api/assets/{id}/download.No matching payment requirements: youracceptedobject is stale or mutated. RefreshPAYMENT-REQUIREDand copyaccepts[0]exactly, unchanged.- Payment 402 with copy-paste scaffold:
use
accepted_copy_pasteexactly as top-levelaccepted, then fillcopy_paste_payment_payload.payloadsigner fields and resubmit. payment_signing_instructionsis authoritative for method-specific payload shape:transfer_method, required/forbidden fields, and expected EIP-712 primary type.x402_method_mismatch: submitted payment method does not match wallet quote method. RefreshPAYMENT-REQUIREDand re-sign with the expected transfer method.- permit2 settle policy errors:
current deployment is routed to CDP-only facilitator endpoints and permit2 settlement may fail upstream.
Default to
eip3009unless you intentionally override transfer method. Incomplete re-download header set: you sent partial entitlement headers, so server blocked purchase fallback to prevent accidental repay. Re-download requires:X-CLIENT-MODE: agent+X-WALLET-ADDRESS+X-PURCHASE-RECEIPT+X-REDOWNLOAD-SIGNATURE+X-REDOWNLOAD-TIMESTAMPfor strict headless agents. Recovery (receipt unavailable):X-WALLET-ADDRESS+ (X-REDOWNLOAD-SESSIONorX-AUTH-SIGNATURE+X-AUTH-TIMESTAMP).flow_hint: Payment header was detected but could not be verified/settled: header parsed but signature/authorization failed verification. Re-sign from latest requirements and verify method-specific shape.FiatTokenV2: invalid signaturein settlement diagnostics: wallet signer is not producing a USDC-compatible EIP-3009 signature for this flow. Use EmblemVault or another compatible signer.- Duplicate payment concern (same signed authorization submitted multiple times): server applies single-flight idempotency by payer + soul + nonce to prevent duplicate settlement attempts in-flight.
- Facilitator schema errors (
paymentPayload is invalid,must match oneOf): for permit2 usepayload.from,payload.permit2Authorization,payload.transaction,payload.signature. Do not sendpayload.permit2. Do not includepayload.authorizationin permit2 mode. - OneOf ambiguity errors (
matches more than one schema,input matches more than one oneOf schemas): do not send mixed payload branches. Use exactly one method:eip3009=>payload.authorization+payload.signatureonly.permit2=>payload.permit2Authorization(+payload.transactionwhen required) +payload.signatureonly. - CDP policy error
permit2 payments are disabled: re-fetch latest paywall and useeip3009(TransferWithAuthorization) flow. network mismatch: submitted=base expected=eip155:8453: top-level payloadnetworkmust beeip155:8453.- CDP facilitator enum note:
agent-signed payload remains CAIP-2 (
eip155:8453). PULL.md remaps facilitator-bound network fields to CDP enum (base) internally.
Strict headless agent re-download headers:
X-CLIENT-MODE: agentX-WALLET-ADDRESSX-PURCHASE-RECEIPTX-REDOWNLOAD-SIGNATUREX-REDOWNLOAD-TIMESTAMP
Clients sign SIWE message content equivalent to:
<domain> wants you to sign in with your Ethereum account:
<wallet_lowercase>
Authenticate wallet ownership for PULL.md. No token transfer or approval.
URI: <origin_uri>
Version: 1
Chain ID: 8453
Nonce: <deterministic_nonce>
Issued At: <iso_timestamp>
Expiration Time: <iso_timestamp_plus_5m>
Request ID: redownload:<asset_id>
Resources:
- urn:pullmd:action:redownload
- urn:pullmd:asset:<asset_id>
Creator publish tools require wallet-auth headers:
X-WALLET-ADDRESSX-AUTH-SIGNATUREX-AUTH-TIMESTAMP
Signing format: SIWE (EIP-4361) message.
<domain> wants you to sign in with your Ethereum account:
<wallet_lowercase>
Authenticate wallet ownership for PULL.md. No token transfer or approval.
URI: <origin_uri>
Version: 1
Chain ID: 8453
Nonce: <deterministic_nonce>
Issued At: <iso_timestamp>
Expiration Time: <iso_timestamp_plus_5m>
Request ID: <tool_action>:creator
Resources:
- urn:pullmd:action:<tool_action>
- urn:pullmd:scope:creator
Where <tool_action> is one of:
publish_listinglist_my_published_listings
Moderation tools require wallet-auth headers:
X-MODERATOR-ADDRESSX-MODERATOR-SIGNATUREX-MODERATOR-TIMESTAMP
Signing format: SIWE (EIP-4361) message.
<domain> wants you to sign in with your Ethereum account:
<wallet_lowercase>
Authenticate wallet ownership for PULL.md. No token transfer or approval.
URI: <origin_uri>
Version: 1
Chain ID: 8453
Nonce: <deterministic_nonce>
Issued At: <iso_timestamp>
Expiration Time: <iso_timestamp_plus_5m>
Request ID: <tool_action>:moderator
Resources:
- urn:pullmd:action:<tool_action>
- urn:pullmd:scope:moderator
Where <tool_action> is one of:
list_moderation_listingsremove_listing_visibility